fullstackgtm 0.10.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 (77) hide show
  1. package/CHANGELOG.md +381 -0
  2. package/INSTALL_FOR_AGENTS.md +87 -0
  3. package/LICENSE +202 -0
  4. package/README.md +230 -0
  5. package/dist/audit.d.ts +7 -0
  6. package/dist/audit.js +202 -0
  7. package/dist/bin.d.ts +2 -0
  8. package/dist/bin.js +6 -0
  9. package/dist/cli.d.ts +38 -0
  10. package/dist/cli.js +915 -0
  11. package/dist/config.d.ts +36 -0
  12. package/dist/config.js +85 -0
  13. package/dist/connector.d.ts +30 -0
  14. package/dist/connector.js +94 -0
  15. package/dist/connectors/hubspot.d.ts +20 -0
  16. package/dist/connectors/hubspot.js +409 -0
  17. package/dist/connectors/hubspotAuth.d.ts +42 -0
  18. package/dist/connectors/hubspotAuth.js +189 -0
  19. package/dist/connectors/salesforce.d.ts +26 -0
  20. package/dist/connectors/salesforce.js +318 -0
  21. package/dist/connectors/salesforceAuth.d.ts +44 -0
  22. package/dist/connectors/salesforceAuth.js +120 -0
  23. package/dist/connectors/stripe.d.ts +27 -0
  24. package/dist/connectors/stripe.js +176 -0
  25. package/dist/credentials.d.ts +75 -0
  26. package/dist/credentials.js +197 -0
  27. package/dist/demo.d.ts +20 -0
  28. package/dist/demo.js +169 -0
  29. package/dist/diff.d.ts +46 -0
  30. package/dist/diff.js +107 -0
  31. package/dist/format.d.ts +3 -0
  32. package/dist/format.js +109 -0
  33. package/dist/index.d.ts +18 -0
  34. package/dist/index.js +17 -0
  35. package/dist/mappings.d.ts +8 -0
  36. package/dist/mappings.js +123 -0
  37. package/dist/mcp-bin.d.ts +2 -0
  38. package/dist/mcp-bin.js +33 -0
  39. package/dist/mcp.d.ts +1 -0
  40. package/dist/mcp.js +140 -0
  41. package/dist/merge.d.ts +48 -0
  42. package/dist/merge.js +145 -0
  43. package/dist/planStore.d.ts +31 -0
  44. package/dist/planStore.js +116 -0
  45. package/dist/rules.d.ts +24 -0
  46. package/dist/rules.js +512 -0
  47. package/dist/sampleData.d.ts +2 -0
  48. package/dist/sampleData.js +115 -0
  49. package/dist/types.d.ts +294 -0
  50. package/dist/types.js +8 -0
  51. package/docs/api.md +72 -0
  52. package/docs/roadmap-to-1.0.md +121 -0
  53. package/llms.txt +25 -0
  54. package/package.json +76 -0
  55. package/src/audit.ts +242 -0
  56. package/src/bin.ts +7 -0
  57. package/src/cli.ts +1042 -0
  58. package/src/config.ts +113 -0
  59. package/src/connector.ts +140 -0
  60. package/src/connectors/hubspot.ts +528 -0
  61. package/src/connectors/hubspotAuth.ts +246 -0
  62. package/src/connectors/salesforce.ts +420 -0
  63. package/src/connectors/salesforceAuth.ts +167 -0
  64. package/src/connectors/stripe.ts +215 -0
  65. package/src/credentials.ts +282 -0
  66. package/src/demo.ts +200 -0
  67. package/src/diff.ts +158 -0
  68. package/src/format.ts +162 -0
  69. package/src/index.ts +129 -0
  70. package/src/mappings.ts +157 -0
  71. package/src/mcp-bin.ts +32 -0
  72. package/src/mcp.ts +185 -0
  73. package/src/merge.ts +235 -0
  74. package/src/planStore.ts +155 -0
  75. package/src/rules.ts +539 -0
  76. package/src/sampleData.ts +117 -0
  77. package/src/types.ts +372 -0
@@ -0,0 +1,528 @@
1
+ import {
2
+ HUBSPOT_DEFAULT_FIELD_MAPPINGS,
3
+ mappedField,
4
+ mappedFields,
5
+ readMappedValue,
6
+ type CrmObjectType,
7
+ type FieldMappings,
8
+ } from "../mappings.ts";
9
+ import type {
10
+ CanonicalAccount,
11
+ CanonicalContact,
12
+ CanonicalDeal,
13
+ CanonicalGtmSnapshot,
14
+ CanonicalUser,
15
+ GtmConnector,
16
+ GtmObjectType,
17
+ PatchOperation,
18
+ PatchOperationResult,
19
+ } from "../types.ts";
20
+
21
+ const DEFAULT_API_BASE_URL = "https://api.hubapi.com";
22
+
23
+ export type HubspotConnectorOptions = {
24
+ /** Returns a HubSpot access token (private app token or OAuth access token). */
25
+ getAccessToken: () => string | Promise<string>;
26
+ /** Per-org canonical-to-provider field overrides. Defaults cover standard properties. */
27
+ fieldMappings?: FieldMappings;
28
+ apiBaseUrl?: string;
29
+ /** Injectable fetch for testing. */
30
+ fetchImpl?: typeof fetch;
31
+ };
32
+
33
+ const OBJECT_PATHS: Partial<Record<GtmObjectType, string>> = {
34
+ account: "companies",
35
+ contact: "contacts",
36
+ deal: "deals",
37
+ };
38
+
39
+ const MAPPING_OBJECT_TYPES: Partial<Record<GtmObjectType, Exclude<CrmObjectType, "owners">>> = {
40
+ account: "accounts",
41
+ contact: "contacts",
42
+ deal: "deals",
43
+ };
44
+
45
+ /**
46
+ * Reference connector for HubSpot.
47
+ *
48
+ * Unlike sync pipelines that drop records they cannot fully resolve, the
49
+ * connector returns every record it can read — including ownerless or
50
+ * amountless deals — so audit rules can surface the gaps instead of hiding
51
+ * them.
52
+ */
53
+ export function createHubspotConnector(options: HubspotConnectorOptions): Required<GtmConnector> {
54
+ const baseUrl = (options.apiBaseUrl ?? DEFAULT_API_BASE_URL).replace(/\/$/, "");
55
+ const fetchImpl = options.fetchImpl ?? fetch;
56
+ const mappings = options.fieldMappings;
57
+
58
+ async function request(path: string, init: RequestInit = {}): Promise<any> {
59
+ const token = await options.getAccessToken();
60
+ const response = await fetchImpl(`${baseUrl}${path}`, {
61
+ ...init,
62
+ headers: {
63
+ Authorization: `Bearer ${token}`,
64
+ "Content-Type": "application/json",
65
+ ...(init.headers ?? {}),
66
+ },
67
+ });
68
+ if (!response.ok) {
69
+ const body = await response.text();
70
+ throw new Error(`HubSpot API error ${response.status}: ${body}`);
71
+ }
72
+ // DELETE and some association writes return 204 with an empty body.
73
+ const text = await response.text();
74
+ return text ? JSON.parse(text) : null;
75
+ }
76
+
77
+ async function list(path: string): Promise<any[]> {
78
+ const results: any[] = [];
79
+ let after: string | undefined;
80
+ const seen = new Set<string>();
81
+ do {
82
+ // Guard against a provider returning a repeating cursor (would loop
83
+ // forever): stop if the same `after` is handed back twice.
84
+ if (after) {
85
+ if (seen.has(after)) break;
86
+ seen.add(after);
87
+ }
88
+ const separator = path.includes("?") ? "&" : "?";
89
+ const data = await request(`${path}${after ? `${separator}after=${encodeURIComponent(after)}` : ""}`);
90
+ results.push(...(data.results ?? []));
91
+ after = data.paging?.next?.after;
92
+ } while (after);
93
+ return results;
94
+ }
95
+
96
+ async function assembleSnapshot(
97
+ fetchObjects: (
98
+ objectType: "companies" | "contacts" | "deals",
99
+ properties: string,
100
+ withAssociations: boolean,
101
+ ) => Promise<any[]>,
102
+ ): Promise<CanonicalGtmSnapshot> {
103
+ const owners = await list("/crm/v3/owners?limit=100");
104
+ const users: CanonicalUser[] = owners
105
+ .filter((owner) => owner.id)
106
+ .map((owner) => ({
107
+ id: String(owner.id),
108
+ provider: "hubspot",
109
+ crmId: String(owner.id),
110
+ identities: [{ provider: "hubspot", externalId: String(owner.id) }],
111
+ name:
112
+ [owner.firstName, owner.lastName].filter(Boolean).join(" ") ||
113
+ stringOrFallback(owner.email, `Owner ${owner.id}`),
114
+ email: stringOrUndefined(owner.email),
115
+ active: owner.archived !== true,
116
+ }));
117
+
118
+ const companyProperties = mappedFields(
119
+ mappings,
120
+ "accounts",
121
+ HUBSPOT_DEFAULT_FIELD_MAPPINGS.accounts,
122
+ ).join(",");
123
+ const companies = await fetchObjects("companies", companyProperties, false);
124
+ const accounts: CanonicalAccount[] = companies
125
+ .filter((company) => company.id)
126
+ .map((company) => {
127
+ const props = company.properties ?? {};
128
+ return {
129
+ id: String(company.id),
130
+ provider: "hubspot",
131
+ crmId: String(company.id),
132
+ identities: [{ provider: "hubspot", externalId: String(company.id) }],
133
+ name: stringOrFallback(
134
+ readMapped(props, "accounts", "name", "name"),
135
+ "Unknown Company",
136
+ ),
137
+ domain: stringOrUndefined(readMapped(props, "accounts", "domain", "domain")),
138
+ industry: stringOrUndefined(readMapped(props, "accounts", "industry", "industry")),
139
+ employeeCount: numberOrUndefined(
140
+ readMapped(props, "accounts", "employeeCount", "numberofemployees"),
141
+ ),
142
+ annualRevenue: numberOrUndefined(
143
+ readMapped(props, "accounts", "annualRevenue", "annualrevenue"),
144
+ ),
145
+ ownerId: stringOrUndefined(
146
+ readMapped(props, "accounts", "ownerId", "hubspot_owner_id"),
147
+ ),
148
+ lastSyncAt: stringOrUndefined(company.updatedAt),
149
+ raw: company,
150
+ };
151
+ });
152
+
153
+ const contactProperties = mappedFields(
154
+ mappings,
155
+ "contacts",
156
+ HUBSPOT_DEFAULT_FIELD_MAPPINGS.contacts,
157
+ ).join(",");
158
+ const hubspotContacts = await fetchObjects("contacts", contactProperties, true);
159
+ const contacts: CanonicalContact[] = hubspotContacts
160
+ .filter((contact) => contact.id)
161
+ .map((contact) => {
162
+ const props = contact.properties ?? {};
163
+ const companyId = contact.associations?.companies?.results?.[0]?.id;
164
+ return {
165
+ id: String(contact.id),
166
+ provider: "hubspot",
167
+ crmId: String(contact.id),
168
+ identities: [{ provider: "hubspot", externalId: String(contact.id) }],
169
+ accountId: companyId ? String(companyId) : undefined,
170
+ firstName: stringOrUndefined(readMapped(props, "contacts", "firstName", "firstname")),
171
+ lastName: stringOrUndefined(readMapped(props, "contacts", "lastName", "lastname")),
172
+ email: stringOrUndefined(readMapped(props, "contacts", "email", "email")),
173
+ phone: stringOrUndefined(readMapped(props, "contacts", "phone", "phone")),
174
+ title: stringOrUndefined(readMapped(props, "contacts", "title", "jobtitle")),
175
+ ownerId: stringOrUndefined(
176
+ readMapped(props, "contacts", "ownerId", "hubspot_owner_id"),
177
+ ),
178
+ lastSyncAt: stringOrUndefined(contact.updatedAt),
179
+ raw: contact,
180
+ };
181
+ });
182
+
183
+ const dealProperties = mappedFields(
184
+ mappings,
185
+ "deals",
186
+ HUBSPOT_DEFAULT_FIELD_MAPPINGS.deals,
187
+ ).join(",");
188
+ const hubspotDeals = await fetchObjects("deals", dealProperties, true);
189
+ const deals: CanonicalDeal[] = hubspotDeals
190
+ .filter((deal) => deal.id)
191
+ .map((deal) => {
192
+ const props = deal.properties ?? {};
193
+ const companyId = deal.associations?.companies?.results?.[0]?.id;
194
+ const stage = stringOrUndefined(readMapped(props, "deals", "stage", "dealstage"));
195
+ const normalizedStage = (stage ?? "").toLowerCase();
196
+ const isWon = normalizedStage.includes("closedwon");
197
+ const isClosed = isWon || normalizedStage.includes("closedlost");
198
+ const forecastCategory = isWon
199
+ ? "closed_won"
200
+ : normalizedStage.includes("closedlost")
201
+ ? "closed_lost"
202
+ : "pipeline";
203
+ const lastActivityAt = stringOrUndefined(
204
+ readMapped(props, "deals", "lastActivityAt", "hs_last_sales_activity_timestamp"),
205
+ );
206
+ return {
207
+ id: String(deal.id),
208
+ provider: "hubspot",
209
+ crmId: String(deal.id),
210
+ identities: [{ provider: "hubspot", externalId: String(deal.id) }],
211
+ accountId: companyId ? String(companyId) : undefined,
212
+ ownerId: stringOrUndefined(readMapped(props, "deals", "ownerId", "hubspot_owner_id")),
213
+ name: stringOrFallback(readMapped(props, "deals", "name", "dealname"), "Untitled Deal"),
214
+ amount: numberOrUndefined(readMapped(props, "deals", "amount", "amount")),
215
+ stage,
216
+ closeDate: stringOrUndefined(
217
+ readMapped(props, "deals", "closeDate", "closedate"),
218
+ )?.split("T")[0],
219
+ dealType: stringOrUndefined(readMapped(props, "deals", "dealType", "dealtype")),
220
+ // hs_deal_stage_probability is already a 0..1 fraction in HubSpot,
221
+ // so it maps straight to the canonical 0..1 unit (no /100, unlike
222
+ // Salesforce's 0..100 Probability field).
223
+ probability: numberOrUndefined(
224
+ readMapped(props, "deals", "probability", "hs_deal_stage_probability"),
225
+ ),
226
+ forecastCategory,
227
+ isClosed,
228
+ isWon,
229
+ // hs_last_sales_activity_timestamp is an ISO timestamp; keep the
230
+ // date portion when it looks like one, otherwise pass through (e.g.
231
+ // an epoch-millis string).
232
+ lastActivityAt: lastActivityAt?.includes("T")
233
+ ? lastActivityAt.split("T")[0]
234
+ : lastActivityAt,
235
+ lastSyncAt: stringOrUndefined(deal.updatedAt),
236
+ raw: deal,
237
+ };
238
+ });
239
+
240
+ return {
241
+ generatedAt: new Date().toISOString(),
242
+ provider: "hubspot",
243
+ users,
244
+ accounts,
245
+ contacts,
246
+ deals,
247
+ // Engagement reads need additional scopes; activity staleness falls back
248
+ // to record sync timestamps until engagements are supported.
249
+ activities: [],
250
+ };
251
+ }
252
+
253
+ async function fetchSnapshot(): Promise<CanonicalGtmSnapshot> {
254
+ return assembleSnapshot((objectType, properties, withAssociations) =>
255
+ list(
256
+ `/crm/v3/objects/${objectType}?limit=100&properties=${properties}${withAssociations ? "&associations=companies" : ""}`,
257
+ ),
258
+ );
259
+ }
260
+
261
+ const MODIFIED_DATE_PROPERTIES = {
262
+ companies: "hs_lastmodifieddate",
263
+ contacts: "lastmodifieddate",
264
+ deals: "hs_lastmodifieddate",
265
+ } as const;
266
+
267
+ async function searchList(
268
+ objectType: "companies" | "contacts" | "deals",
269
+ properties: string,
270
+ sinceMs: number,
271
+ ): Promise<any[]> {
272
+ const results: any[] = [];
273
+ let after: string | undefined;
274
+ // HubSpot's search API hard-caps at 10,000 results (100 pages of 100) and
275
+ // 400s past it. Stop at the boundary rather than throwing mid-sync; an
276
+ // incremental window this large should fall back to a full snapshot.
277
+ const MAX_PAGES = 100;
278
+ for (let page = 0; page < MAX_PAGES; page += 1) {
279
+ const data = await request(`/crm/v3/objects/${objectType}/search`, {
280
+ method: "POST",
281
+ body: JSON.stringify({
282
+ filterGroups: [
283
+ {
284
+ filters: [
285
+ {
286
+ propertyName: MODIFIED_DATE_PROPERTIES[objectType],
287
+ operator: "GTE",
288
+ value: String(sinceMs),
289
+ },
290
+ ],
291
+ },
292
+ ],
293
+ properties: properties.split(","),
294
+ limit: 100,
295
+ ...(after ? { after } : {}),
296
+ }),
297
+ });
298
+ results.push(...(data.results ?? []));
299
+ const next = data.paging?.next?.after;
300
+ if (!next || next === after) break;
301
+ after = next;
302
+ }
303
+ return results;
304
+ }
305
+
306
+ /**
307
+ * Records modified since `sinceIso`, via the CRM search API. HubSpot search
308
+ * results carry no associations, so contact/deal accountIds are absent in
309
+ * change feeds — consumers merging deltas must preserve associations from
310
+ * the last full snapshot.
311
+ */
312
+ async function fetchChanges(sinceIso: string): Promise<CanonicalGtmSnapshot> {
313
+ const sinceMs = Date.parse(sinceIso);
314
+ if (!Number.isFinite(sinceMs)) throw new Error(`Invalid since timestamp: ${sinceIso}`);
315
+ return assembleSnapshot((objectType, properties) =>
316
+ searchList(objectType, properties, sinceMs),
317
+ );
318
+ }
319
+
320
+ // HubSpot-defined association type ids from a task engagement to its parent.
321
+ const TASK_ASSOCIATION_TYPE_IDS: Partial<Record<GtmObjectType, number>> = {
322
+ contact: 204,
323
+ account: 192,
324
+ deal: 216,
325
+ };
326
+
327
+ function humanizeField(field: string): string {
328
+ return field
329
+ .replace(/_task$/, "")
330
+ .replace(/[_-]+/g, " ")
331
+ .replace(/\b\w/g, (c) => c.toUpperCase())
332
+ .trim();
333
+ }
334
+
335
+ async function setField(operation: PatchOperation): Promise<PatchOperationResult> {
336
+ const objectPath = OBJECT_PATHS[operation.objectType];
337
+ const mappingType = MAPPING_OBJECT_TYPES[operation.objectType];
338
+ if (!objectPath || !mappingType || !operation.field) {
339
+ return {
340
+ operationId: operation.id,
341
+ status: "skipped",
342
+ detail: "Field writes are only supported for accounts, contacts, and deals with an explicit field.",
343
+ };
344
+ }
345
+ const defaults = HUBSPOT_DEFAULT_FIELD_MAPPINGS[mappingType] ?? {};
346
+ const property = mappedField(
347
+ mappings,
348
+ mappingType,
349
+ operation.field,
350
+ defaults[operation.field] ?? operation.field,
351
+ );
352
+ const value =
353
+ operation.operation === "clear_field" ? "" : String(operation.afterValue ?? "");
354
+ const response = await request(
355
+ `/crm/v3/objects/${objectPath}/${encodeURIComponent(operation.objectId)}`,
356
+ { method: "PATCH", body: JSON.stringify({ properties: { [property]: value } }) },
357
+ );
358
+ return {
359
+ operationId: operation.id,
360
+ status: "applied",
361
+ detail: `Set ${property} on ${objectPath}/${operation.objectId}.`,
362
+ providerData: { id: response?.id, property },
363
+ };
364
+ }
365
+
366
+ async function linkRecord(operation: PatchOperation): Promise<PatchOperationResult> {
367
+ // Associate a deal or contact with a company (the only link the built-in
368
+ // rules emit — missing-deal-account). afterValue is the target company id.
369
+ const fromPath = OBJECT_PATHS[operation.objectType];
370
+ if ((operation.objectType !== "deal" && operation.objectType !== "contact") || !fromPath) {
371
+ return {
372
+ operationId: operation.id,
373
+ status: "skipped",
374
+ detail: "link_record is supported for deals and contacts (to a company).",
375
+ };
376
+ }
377
+ const companyId = String(operation.afterValue ?? "");
378
+ if (!companyId) {
379
+ return { operationId: operation.id, status: "skipped", detail: "link_record needs a target company id." };
380
+ }
381
+ await request(
382
+ `/crm/v4/objects/${fromPath}/${encodeURIComponent(operation.objectId)}/associations/default/companies/${encodeURIComponent(companyId)}`,
383
+ { method: "PUT" },
384
+ );
385
+ return {
386
+ operationId: operation.id,
387
+ status: "applied",
388
+ detail: `Linked ${fromPath}/${operation.objectId} to company ${companyId}.`,
389
+ providerData: { companyId },
390
+ };
391
+ }
392
+
393
+ async function createTask(operation: PatchOperation): Promise<PatchOperationResult> {
394
+ const associationTypeId = TASK_ASSOCIATION_TYPE_IDS[operation.objectType];
395
+ if (associationTypeId === undefined) {
396
+ return {
397
+ operationId: operation.id,
398
+ status: "skipped",
399
+ detail: "Tasks can be attached to accounts, contacts, and deals.",
400
+ };
401
+ }
402
+ const subject = operation.field ? humanizeField(operation.field) : "Follow up";
403
+ const body = String(operation.afterValue ?? operation.reason ?? "");
404
+ const response = await request(`/crm/v3/objects/tasks`, {
405
+ method: "POST",
406
+ body: JSON.stringify({
407
+ properties: {
408
+ hs_task_subject: subject,
409
+ hs_task_body: body,
410
+ hs_task_status: "NOT_STARTED",
411
+ hs_task_priority: "MEDIUM",
412
+ hs_timestamp: Date.now(),
413
+ },
414
+ associations: [
415
+ {
416
+ to: { id: operation.objectId },
417
+ types: [
418
+ { associationCategory: "HUBSPOT_DEFINED", associationTypeId },
419
+ ],
420
+ },
421
+ ],
422
+ }),
423
+ });
424
+ return {
425
+ operationId: operation.id,
426
+ status: "applied",
427
+ detail: `Created task "${subject}" on ${operation.objectType}/${operation.objectId}.`,
428
+ providerData: { id: response?.id },
429
+ };
430
+ }
431
+
432
+ async function archiveRecord(operation: PatchOperation): Promise<PatchOperationResult> {
433
+ const objectPath = OBJECT_PATHS[operation.objectType];
434
+ if (!objectPath) {
435
+ return {
436
+ operationId: operation.id,
437
+ status: "skipped",
438
+ detail: "archive_record is supported for accounts, contacts, and deals.",
439
+ };
440
+ }
441
+ await request(`/crm/v3/objects/${objectPath}/${encodeURIComponent(operation.objectId)}`, {
442
+ method: "DELETE",
443
+ });
444
+ return {
445
+ operationId: operation.id,
446
+ status: "applied",
447
+ detail: `Archived ${objectPath}/${operation.objectId}.`,
448
+ };
449
+ }
450
+
451
+ async function applyOperation(operation: PatchOperation): Promise<PatchOperationResult> {
452
+ try {
453
+ switch (operation.operation) {
454
+ case "set_field":
455
+ case "clear_field":
456
+ return await setField(operation);
457
+ case "link_record":
458
+ return await linkRecord(operation);
459
+ case "create_task":
460
+ return await createTask(operation);
461
+ case "archive_record":
462
+ return await archiveRecord(operation);
463
+ default:
464
+ return {
465
+ operationId: operation.id,
466
+ status: "skipped",
467
+ detail: `Unknown operation ${operation.operation}.`,
468
+ };
469
+ }
470
+ } catch (error) {
471
+ return {
472
+ operationId: operation.id,
473
+ status: "failed",
474
+ detail: error instanceof Error ? error.message : String(error),
475
+ };
476
+ }
477
+ }
478
+
479
+ function readMapped(
480
+ source: Record<string, unknown>,
481
+ objectType: CrmObjectType,
482
+ targetField: string,
483
+ fallbackField: string,
484
+ ) {
485
+ return readMappedValue(source, mappings, objectType, targetField, fallbackField);
486
+ }
487
+
488
+ async function readField(
489
+ objectType: GtmObjectType,
490
+ objectId: string,
491
+ field: string,
492
+ ): Promise<unknown> {
493
+ const objectPath = OBJECT_PATHS[objectType];
494
+ const mappingType = MAPPING_OBJECT_TYPES[objectType];
495
+ if (!objectPath || !mappingType) {
496
+ throw new Error(`Field reads are only supported for accounts, contacts, and deals.`);
497
+ }
498
+ const defaults = HUBSPOT_DEFAULT_FIELD_MAPPINGS[mappingType] ?? {};
499
+ const property = mappedField(mappings, mappingType, field, defaults[field] ?? field);
500
+ const data = await request(
501
+ `/crm/v3/objects/${objectPath}/${encodeURIComponent(objectId)}?properties=${encodeURIComponent(property)}`,
502
+ );
503
+ return data?.properties?.[property] ?? null;
504
+ }
505
+
506
+ return {
507
+ provider: "hubspot",
508
+ fetchSnapshot,
509
+ fetchChanges,
510
+ applyOperation,
511
+ readField,
512
+ };
513
+ }
514
+
515
+ function stringOrUndefined(value: unknown): string | undefined {
516
+ if (value === undefined || value === null || value === "") return undefined;
517
+ return String(value);
518
+ }
519
+
520
+ function stringOrFallback(value: unknown, fallback: string): string {
521
+ return stringOrUndefined(value) ?? fallback;
522
+ }
523
+
524
+ function numberOrUndefined(value: unknown): number | undefined {
525
+ if (value === undefined || value === null || value === "") return undefined;
526
+ const parsed = typeof value === "number" ? value : Number.parseFloat(String(value));
527
+ return Number.isFinite(parsed) ? parsed : undefined;
528
+ }