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,157 @@
1
+ export type CrmObjectType = "owners" | "accounts" | "contacts" | "deals";
2
+
3
+ export type FieldMappings = Partial<
4
+ Record<CrmObjectType | string, Record<string, string>>
5
+ >;
6
+
7
+ export const HUBSPOT_DEFAULT_FIELD_MAPPINGS: Record<
8
+ Exclude<CrmObjectType, "owners">,
9
+ Record<string, string>
10
+ > = {
11
+ accounts: {
12
+ name: "name",
13
+ domain: "domain",
14
+ industry: "industry",
15
+ employeeCount: "numberofemployees",
16
+ annualRevenue: "annualrevenue",
17
+ ownerId: "hubspot_owner_id",
18
+ },
19
+ contacts: {
20
+ firstName: "firstname",
21
+ lastName: "lastname",
22
+ email: "email",
23
+ phone: "phone",
24
+ title: "jobtitle",
25
+ ownerId: "hubspot_owner_id",
26
+ },
27
+ deals: {
28
+ name: "dealname",
29
+ amount: "amount",
30
+ closeDate: "closedate",
31
+ stage: "dealstage",
32
+ dealType: "dealtype",
33
+ ownerId: "hubspot_owner_id",
34
+ probability: "hs_deal_stage_probability",
35
+ lastActivityAt: "hs_last_sales_activity_timestamp",
36
+ nextStep: "hs_next_step",
37
+ },
38
+ };
39
+
40
+ export const SALESFORCE_DEFAULT_FIELD_MAPPINGS: Record<
41
+ CrmObjectType,
42
+ Record<string, string>
43
+ > = {
44
+ owners: {
45
+ id: "Id",
46
+ name: "Name",
47
+ email: "Email",
48
+ title: "Title",
49
+ isActive: "IsActive",
50
+ },
51
+ accounts: {
52
+ id: "Id",
53
+ name: "Name",
54
+ domain: "Website",
55
+ industry: "Industry",
56
+ employeeCount: "NumberOfEmployees",
57
+ annualRevenue: "AnnualRevenue",
58
+ ownerId: "OwnerId",
59
+ },
60
+ contacts: {
61
+ id: "Id",
62
+ firstName: "FirstName",
63
+ lastName: "LastName",
64
+ email: "Email",
65
+ phone: "Phone",
66
+ title: "Title",
67
+ accountId: "AccountId",
68
+ ownerId: "OwnerId",
69
+ },
70
+ deals: {
71
+ id: "Id",
72
+ name: "Name",
73
+ amount: "Amount",
74
+ closeDate: "CloseDate",
75
+ stage: "StageName",
76
+ dealType: "Type",
77
+ probability: "Probability",
78
+ isClosed: "IsClosed",
79
+ isWon: "IsWon",
80
+ ownerId: "OwnerId",
81
+ accountId: "AccountId",
82
+ lastActivityAt: "LastActivityDate",
83
+ forecastCategoryName: "ForecastCategory",
84
+ nextStep: "NextStep",
85
+ },
86
+ };
87
+
88
+ const OBJECT_ALIASES: Record<CrmObjectType, string[]> = {
89
+ owners: ["owners", "users"],
90
+ accounts: ["accounts", "companies"],
91
+ contacts: ["contacts"],
92
+ deals: ["deals", "opportunities"],
93
+ };
94
+
95
+ export function normalizeFieldMappings(value: unknown): FieldMappings {
96
+ if (!value || typeof value !== "object" || Array.isArray(value)) return {};
97
+ const normalized: FieldMappings = {};
98
+ for (const [objectName, mapping] of Object.entries(value)) {
99
+ if (!mapping || typeof mapping !== "object" || Array.isArray(mapping)) {
100
+ continue;
101
+ }
102
+ const clean: Record<string, string> = {};
103
+ for (const [targetField, providerField] of Object.entries(mapping)) {
104
+ if (typeof providerField === "string" && providerField.trim()) {
105
+ clean[targetField] = providerField.trim();
106
+ }
107
+ }
108
+ if (Object.keys(clean).length > 0) normalized[objectName] = clean;
109
+ }
110
+ return normalized;
111
+ }
112
+
113
+ export function mappedField(
114
+ mappings: FieldMappings | undefined,
115
+ objectType: CrmObjectType,
116
+ targetField: string,
117
+ fallbackField: string,
118
+ ): string {
119
+ const normalized = normalizeFieldMappings(mappings);
120
+ for (const alias of OBJECT_ALIASES[objectType]) {
121
+ const mapped = normalized[alias]?.[targetField];
122
+ if (mapped) return mapped;
123
+ }
124
+ return fallbackField;
125
+ }
126
+
127
+ export function mappedFields(
128
+ mappings: FieldMappings | undefined,
129
+ objectType: CrmObjectType,
130
+ defaults: Record<string, string>,
131
+ ): string[] {
132
+ const fields = Object.entries(defaults).map(([targetField, fallbackField]) =>
133
+ mappedField(mappings, objectType, targetField, fallbackField),
134
+ );
135
+ return Array.from(new Set(fields)).filter(Boolean);
136
+ }
137
+
138
+ export function readMappedValue(
139
+ source: Record<string, unknown>,
140
+ mappings: FieldMappings | undefined,
141
+ objectType: CrmObjectType,
142
+ targetField: string,
143
+ fallbackField: string,
144
+ ): unknown {
145
+ return readPath(
146
+ source,
147
+ mappedField(mappings, objectType, targetField, fallbackField),
148
+ );
149
+ }
150
+
151
+ function readPath(source: Record<string, unknown>, path: string): unknown {
152
+ if (Object.prototype.hasOwnProperty.call(source, path)) return source[path];
153
+ return path.split(".").reduce<unknown>((value, segment) => {
154
+ if (!value || typeof value !== "object") return undefined;
155
+ return (value as Record<string, unknown>)[segment];
156
+ }, source);
157
+ }
package/src/mcp-bin.ts ADDED
@@ -0,0 +1,32 @@
1
+ #!/usr/bin/env node
2
+ export {};
3
+
4
+ // The MCP server needs the optional peer dependencies. Import dynamically so
5
+ // a missing peer produces install guidance instead of a module-load stack
6
+ // trace — `npx fullstackgtm-mcp` alone never installs optional peers.
7
+ const MISSING_PEER_HELP = `The MCP server needs the optional peer dependencies @modelcontextprotocol/sdk and zod.
8
+
9
+ In a project:
10
+ npm install fullstackgtm @modelcontextprotocol/sdk zod
11
+
12
+ Zero-install:
13
+ npx -p fullstackgtm -p @modelcontextprotocol/sdk -p zod fullstackgtm-mcp`;
14
+
15
+ function isMissingPeerError(error: unknown): boolean {
16
+ if (!(error instanceof Error)) return false;
17
+ const code = (error as NodeJS.ErrnoException).code;
18
+ if (code !== "ERR_MODULE_NOT_FOUND" && code !== "MODULE_NOT_FOUND") return false;
19
+ return error.message.includes("@modelcontextprotocol/sdk") || error.message.includes("zod");
20
+ }
21
+
22
+ try {
23
+ const { startMcpServer } = await import("./mcp.ts");
24
+ await startMcpServer();
25
+ } catch (error) {
26
+ if (isMissingPeerError(error)) {
27
+ console.error(MISSING_PEER_HELP);
28
+ } else {
29
+ console.error(error instanceof Error ? error.message : String(error));
30
+ }
31
+ process.exitCode = 1;
32
+ }
package/src/mcp.ts ADDED
@@ -0,0 +1,185 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
+ import { z } from "zod/v4";
6
+ import { auditSnapshot, defaultPolicy } from "./audit.ts";
7
+ import { loadConfig, mergePolicy, resolveConfiguredRules } from "./config.ts";
8
+ import { applyPatchPlan } from "./connector.ts";
9
+ import { createHubspotConnector } from "./connectors/hubspot.ts";
10
+ import { createSalesforceConnector } from "./connectors/salesforce.ts";
11
+ import { createStripeConnector } from "./connectors/stripe.ts";
12
+ import {
13
+ getCredential,
14
+ resolveHubspotAccessToken,
15
+ resolveSalesforceConnection,
16
+ } from "./credentials.ts";
17
+ import { generateDemoSnapshot } from "./demo.ts";
18
+ import type { FieldMappings } from "./mappings.ts";
19
+ import { formatPatchPlanRun, patchPlanToMarkdown } from "./format.ts";
20
+ import { builtinAuditRules } from "./rules.ts";
21
+ import { sampleSnapshot } from "./sampleData.ts";
22
+ import type { CanonicalGtmSnapshot, GtmConnector, PatchPlan } from "./types.ts";
23
+
24
+ function content(value: unknown) {
25
+ return {
26
+ content: [
27
+ {
28
+ type: "text" as const,
29
+ text: typeof value === "string" ? value : JSON.stringify(value, null, 2),
30
+ },
31
+ ],
32
+ };
33
+ }
34
+
35
+ async function connectorFor(provider: string): Promise<GtmConnector> {
36
+ if (provider === "hubspot") {
37
+ const token = process.env.HUBSPOT_ACCESS_TOKEN ?? (await resolveHubspotAccessToken());
38
+ if (!token) {
39
+ throw new Error(
40
+ "No HubSpot credentials. Run `fullstackgtm login hubspot` or set HUBSPOT_ACCESS_TOKEN in the MCP server environment.",
41
+ );
42
+ }
43
+ return createHubspotConnector({ getAccessToken: () => token });
44
+ }
45
+ if (provider === "salesforce") {
46
+ const connection =
47
+ process.env.SALESFORCE_ACCESS_TOKEN && process.env.SALESFORCE_INSTANCE_URL
48
+ ? {
49
+ accessToken: process.env.SALESFORCE_ACCESS_TOKEN,
50
+ instanceUrl: process.env.SALESFORCE_INSTANCE_URL,
51
+ }
52
+ : await resolveSalesforceConnection();
53
+ if (!connection) {
54
+ throw new Error(
55
+ "No Salesforce credentials. Run `fullstackgtm login salesforce` or set SALESFORCE_ACCESS_TOKEN and SALESFORCE_INSTANCE_URL in the MCP server environment.",
56
+ );
57
+ }
58
+ return createSalesforceConnector({
59
+ getConnection: () => connection,
60
+ fieldMappings:
61
+ ((connection as { fieldMappings?: unknown }).fieldMappings as
62
+ | FieldMappings
63
+ | undefined) ?? undefined,
64
+ });
65
+ }
66
+ if (provider === "stripe") {
67
+ const key = process.env.STRIPE_SECRET_KEY ?? getCredential("stripe")?.accessToken;
68
+ if (!key) {
69
+ throw new Error(
70
+ "No Stripe credentials. Run `fullstackgtm login stripe` or set STRIPE_SECRET_KEY in the MCP server environment.",
71
+ );
72
+ }
73
+ return createStripeConnector({ getApiKey: () => key });
74
+ }
75
+ throw new Error(
76
+ `Unknown provider: ${provider}. Supported providers: hubspot, salesforce, stripe`,
77
+ );
78
+ }
79
+
80
+ async function readSnapshot(
81
+ provider?: string,
82
+ inputPath?: string,
83
+ ): Promise<CanonicalGtmSnapshot> {
84
+ if (provider === "demo") return generateDemoSnapshot();
85
+ if (provider && provider !== "sample") {
86
+ const connector = await connectorFor(provider);
87
+ return connector.fetchSnapshot();
88
+ }
89
+ if (!inputPath) return sampleSnapshot;
90
+ return JSON.parse(
91
+ readFileSync(resolve(process.cwd(), inputPath), "utf8"),
92
+ ) as CanonicalGtmSnapshot;
93
+ }
94
+
95
+ function packageVersion() {
96
+ try {
97
+ const raw = readFileSync(new URL("../package.json", import.meta.url), "utf8");
98
+ return (JSON.parse(raw) as { version?: string }).version ?? "0.0.0";
99
+ } catch {
100
+ return "0.0.0";
101
+ }
102
+ }
103
+
104
+ export async function startMcpServer() {
105
+ const server = new McpServer({
106
+ name: "fullstackgtm",
107
+ version: packageVersion(),
108
+ });
109
+
110
+ server.registerTool(
111
+ "fullstackgtm_audit",
112
+ {
113
+ title: "GTM Ops Audit",
114
+ description:
115
+ "Run a dry-run GTM hygiene audit and return a reviewable patch plan. " +
116
+ "Reads from the sample dataset, a snapshot file, or a live provider.",
117
+ inputSchema: {
118
+ provider: z.enum(["sample", "demo", "hubspot", "salesforce", "stripe"]).optional(),
119
+ inputPath: z.string().optional(),
120
+ configPath: z.string().optional(),
121
+ rules: z.array(z.string()).optional(),
122
+ output: z.enum(["json", "markdown"]).optional(),
123
+ today: z.string().optional(),
124
+ staleDealDays: z.number().int().positive().optional(),
125
+ },
126
+ },
127
+ async ({ provider, inputPath, configPath, rules, output, today, staleDealDays }) => {
128
+ const loaded = configPath ? loadConfig(configPath) : null;
129
+ const policy = mergePolicy(defaultPolicy(today), loaded?.config);
130
+ if (today) policy.today = today;
131
+ if (staleDealDays !== undefined) policy.staleDealDays = staleDealDays;
132
+ const ruleSet = await resolveConfiguredRules(loaded);
133
+ const selected = rules?.length
134
+ ? ruleSet.filter((rule) => rules.includes(rule.id))
135
+ : ruleSet;
136
+ const plan = auditSnapshot(await readSnapshot(provider, inputPath), policy, selected);
137
+ return content(output === "markdown" ? patchPlanToMarkdown(plan) : plan);
138
+ },
139
+ );
140
+
141
+ server.registerTool(
142
+ "fullstackgtm_rules",
143
+ {
144
+ title: "List Audit Rules",
145
+ description: "List the built-in deterministic audit rules with ids and descriptions.",
146
+ inputSchema: {},
147
+ },
148
+ async () => {
149
+ return content(
150
+ builtinAuditRules.map(({ id, title, description }) => ({ id, title, description })),
151
+ );
152
+ },
153
+ );
154
+
155
+ server.registerTool(
156
+ "fullstackgtm_apply",
157
+ {
158
+ title: "Apply Approved Patch Operations",
159
+ description:
160
+ "Apply explicitly approved operations from a patch plan through a provider " +
161
+ "connector. Operations not listed in approvedOperationIds are never written, " +
162
+ "and requires_human_* placeholders need a value override.",
163
+ inputSchema: {
164
+ provider: z.enum(["hubspot", "salesforce"]),
165
+ planPath: z.string(),
166
+ approvedOperationIds: z.array(z.string()).min(1),
167
+ valueOverrides: z.record(z.string(), z.string()).optional(),
168
+ output: z.enum(["json", "markdown"]).optional(),
169
+ },
170
+ },
171
+ async ({ provider, planPath, approvedOperationIds, valueOverrides, output }) => {
172
+ const plan = JSON.parse(
173
+ readFileSync(resolve(process.cwd(), planPath), "utf8"),
174
+ ) as PatchPlan;
175
+ const run = await applyPatchPlan(await connectorFor(provider), plan, {
176
+ approvedOperationIds,
177
+ valueOverrides,
178
+ });
179
+ return content(output === "markdown" ? formatPatchPlanRun(run) : run);
180
+ },
181
+ );
182
+
183
+ const transport = new StdioServerTransport();
184
+ await server.connect(transport);
185
+ }
package/src/merge.ts ADDED
@@ -0,0 +1,235 @@
1
+ import type {
2
+ CanonicalAccount,
3
+ CanonicalContact,
4
+ CanonicalGtmSnapshot,
5
+ CanonicalUser,
6
+ ProviderIdentity,
7
+ } from "./types.ts";
8
+
9
+ /**
10
+ * Entity resolution across systems. GTM data disagrees because the same
11
+ * real-world entity lives in several tools under different ids; merging
12
+ * collapses canonical records onto deterministic match keys (account domain,
13
+ * contact/user email) while every original system id survives as an identity
14
+ * claim. Deals and activities are never merged — they are provider-unique —
15
+ * but their references are re-pointed at the merged records.
16
+ *
17
+ * Merging never invents data: the first source wins for conflicting fields,
18
+ * and every disagreement is reported, not silently resolved.
19
+ */
20
+
21
+ export type MergeMatch = {
22
+ type: "user" | "account" | "contact";
23
+ primaryId: string;
24
+ mergedIds: string[];
25
+ matchedBy: "email" | "domain" | "name";
26
+ };
27
+
28
+ export type MergeConflict = {
29
+ type: "user" | "account" | "contact";
30
+ recordId: string;
31
+ field: string;
32
+ values: Array<{ provider: string; value: unknown }>;
33
+ };
34
+
35
+ /**
36
+ * A same-name collision that was NOT auto-merged (different or absent
37
+ * domains). Surfaced for human review rather than silently combined —
38
+ * fabricating a merge between two real companies both named "Acme" would
39
+ * corrupt deals, contacts, and attribution.
40
+ */
41
+ export type MergeSuggestion = {
42
+ type: "account";
43
+ name: string;
44
+ recordIds: string[];
45
+ };
46
+
47
+ export type MergeReport = {
48
+ sources: string[];
49
+ matches: MergeMatch[];
50
+ conflicts: MergeConflict[];
51
+ suggestions: MergeSuggestion[];
52
+ };
53
+
54
+ const CONFLICT_IGNORED_FIELDS = new Set([
55
+ "id", "provider", "crmId", "identities", "raw", "lastSyncAt", "lastActivityAt", "ownerId", "accountId",
56
+ ]);
57
+
58
+ function normalizeDomain(domain?: string): string | undefined {
59
+ if (!domain) return undefined;
60
+ return domain.trim().toLowerCase().replace(/^https?:\/\//, "").replace(/^www\./, "").replace(/\/.*$/, "") || undefined;
61
+ }
62
+
63
+ function normalizeEmail(email?: string): string | undefined {
64
+ const normalized = email?.trim().toLowerCase();
65
+ return normalized || undefined;
66
+ }
67
+
68
+ function identityOf(record: { provider?: string; crmId?: string; id: string }): ProviderIdentity {
69
+ return { provider: record.provider ?? "unknown", externalId: record.crmId ?? record.id };
70
+ }
71
+
72
+ type Mergeable = CanonicalUser | CanonicalAccount | CanonicalContact;
73
+
74
+ export function mergeSnapshots(snapshots: CanonicalGtmSnapshot[]): {
75
+ snapshot: CanonicalGtmSnapshot;
76
+ report: MergeReport;
77
+ } {
78
+ if (snapshots.length === 0) throw new Error("mergeSnapshots needs at least one snapshot.");
79
+ const sources = snapshots.map((snapshot) => snapshot.provider);
80
+ const matches: MergeMatch[] = [];
81
+ const conflicts: MergeConflict[] = [];
82
+ // Original id (per source snapshot) → merged id, used to re-point references.
83
+ const idRemap = new Map<string, string>();
84
+
85
+ function namespaced(provider: string, id: string) {
86
+ return `${provider}:${id}`;
87
+ }
88
+
89
+ function mergeCollection<T extends Mergeable>(
90
+ type: MergeMatch["type"],
91
+ collections: Array<{ provider: string; records: T[] }>,
92
+ keysOf: (record: T) => Array<{ key: string; matchedBy: MergeMatch["matchedBy"] }>,
93
+ ): T[] {
94
+ const merged: T[] = [];
95
+ const byKey = new Map<string, T>();
96
+
97
+ for (const { provider, records } of collections) {
98
+ for (const record of records) {
99
+ const sourceId = namespaced(provider, record.id);
100
+ const keyed = keysOf(record);
101
+ const existing = keyed
102
+ .map((entry) => ({ ...entry, match: byKey.get(`${entry.matchedBy}:${entry.key}`) }))
103
+ .find((entry) => entry.match);
104
+
105
+ if (!existing?.match) {
106
+ const copy: T = {
107
+ ...record,
108
+ id: sourceId,
109
+ provider: record.provider ?? provider,
110
+ identities: [identityOf({ ...record, provider: record.provider ?? provider })],
111
+ };
112
+ merged.push(copy);
113
+ idRemap.set(sourceId, sourceId);
114
+ for (const entry of keyed) byKey.set(`${entry.matchedBy}:${entry.key}`, copy);
115
+ continue;
116
+ }
117
+
118
+ const primary = existing.match;
119
+ idRemap.set(sourceId, primary.id);
120
+ primary.identities = [
121
+ ...(primary.identities ?? []),
122
+ identityOf({ ...record, provider: record.provider ?? provider }),
123
+ ];
124
+ const match = matches.find(
125
+ (candidate) => candidate.type === type && candidate.primaryId === primary.id,
126
+ );
127
+ if (match) match.mergedIds.push(sourceId);
128
+ else matches.push({ type, primaryId: primary.id, mergedIds: [sourceId], matchedBy: existing.matchedBy });
129
+
130
+ // First source wins; fill gaps, report disagreements.
131
+ for (const [field, value] of Object.entries(record)) {
132
+ if (CONFLICT_IGNORED_FIELDS.has(field) || value === undefined) continue;
133
+ const current = (primary as Record<string, unknown>)[field];
134
+ if (current === undefined) {
135
+ (primary as Record<string, unknown>)[field] = value;
136
+ } else if (JSON.stringify(current) !== JSON.stringify(value)) {
137
+ conflicts.push({
138
+ type,
139
+ recordId: primary.id,
140
+ field,
141
+ values: [
142
+ { provider: primary.provider ?? "unknown", value: current },
143
+ { provider: record.provider ?? provider, value },
144
+ ],
145
+ });
146
+ }
147
+ }
148
+ }
149
+ }
150
+ return merged;
151
+ }
152
+
153
+ const users = mergeCollection(
154
+ "user",
155
+ snapshots.map((snapshot) => ({ provider: snapshot.provider, records: snapshot.users })),
156
+ (user) => {
157
+ const email = normalizeEmail(user.email);
158
+ return email ? [{ key: email, matchedBy: "email" as const }] : [];
159
+ },
160
+ );
161
+
162
+ const accounts = mergeCollection(
163
+ "account",
164
+ snapshots.map((snapshot) => ({ provider: snapshot.provider, records: snapshot.accounts })),
165
+ // Auto-merge accounts ONLY on a shared normalized domain. Name is a weak,
166
+ // collision-prone key (every "Acme Inc"); name-only collisions become
167
+ // review suggestions below instead of silent merges.
168
+ (account) => {
169
+ const domain = normalizeDomain(account.domain);
170
+ return domain ? [{ key: domain, matchedBy: "domain" as const }] : [];
171
+ },
172
+ );
173
+
174
+ const suggestions: MergeSuggestion[] = [];
175
+ const byName = new Map<string, string[]>();
176
+ for (const account of accounts) {
177
+ const key = account.name.trim().toLowerCase();
178
+ if (!key) continue;
179
+ byName.set(key, [...(byName.get(key) ?? []), account.id]);
180
+ }
181
+ for (const [, recordIds] of byName) {
182
+ if (recordIds.length > 1) {
183
+ const name = accounts.find((account) => account.id === recordIds[0])!.name;
184
+ suggestions.push({ type: "account", name, recordIds });
185
+ }
186
+ }
187
+
188
+ const contacts = mergeCollection(
189
+ "contact",
190
+ snapshots.map((snapshot) => ({ provider: snapshot.provider, records: snapshot.contacts })),
191
+ (contact) => {
192
+ const email = normalizeEmail(contact.email);
193
+ return email ? [{ key: email, matchedBy: "email" as const }] : [];
194
+ },
195
+ );
196
+
197
+ const remapRef = (provider: string, id?: string) =>
198
+ id === undefined ? undefined : idRemap.get(`${provider}:${id}`) ?? namespaced(provider, id);
199
+
200
+ const deals = snapshots.flatMap((snapshot) =>
201
+ snapshot.deals.map((deal) => ({
202
+ ...deal,
203
+ id: namespaced(snapshot.provider, deal.id),
204
+ provider: deal.provider ?? snapshot.provider,
205
+ identities: [identityOf({ ...deal, provider: deal.provider ?? snapshot.provider })],
206
+ accountId: remapRef(snapshot.provider, deal.accountId),
207
+ ownerId: remapRef(snapshot.provider, deal.ownerId),
208
+ })),
209
+ );
210
+
211
+ const activities = snapshots.flatMap((snapshot) =>
212
+ snapshot.activities.map((activity) => ({
213
+ ...activity,
214
+ id: namespaced(snapshot.provider, activity.id),
215
+ provider: activity.provider ?? snapshot.provider,
216
+ accountId: remapRef(snapshot.provider, activity.accountId),
217
+ contactId: remapRef(snapshot.provider, activity.contactId),
218
+ dealId: remapRef(snapshot.provider, activity.dealId),
219
+ ownerId: remapRef(snapshot.provider, activity.ownerId),
220
+ })),
221
+ );
222
+
223
+ return {
224
+ snapshot: {
225
+ generatedAt: snapshots.map((snapshot) => snapshot.generatedAt).sort().at(-1)!,
226
+ provider: "merged",
227
+ users,
228
+ accounts,
229
+ contacts,
230
+ deals,
231
+ activities,
232
+ },
233
+ report: { sources, matches, conflicts, suggestions },
234
+ };
235
+ }