fullstackgtm 0.44.0 → 0.46.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/CHANGELOG.md +293 -1
- package/INSTALL_FOR_AGENTS.md +13 -11
- package/README.md +45 -24
- package/dist/audit.d.ts +3 -1
- package/dist/audit.js +29 -2
- package/dist/backfill.d.ts +86 -0
- package/dist/backfill.js +251 -0
- package/dist/bin.js +4 -1
- package/dist/cli/audit.d.ts +15 -0
- package/dist/cli/audit.js +392 -0
- package/dist/cli/auth.d.ts +82 -0
- package/dist/cli/auth.js +607 -0
- package/dist/cli/backfill.d.ts +1 -0
- package/dist/cli/backfill.js +125 -0
- package/dist/cli/backfillRuns.d.ts +1 -0
- package/dist/cli/backfillRuns.js +187 -0
- package/dist/cli/call.d.ts +1 -0
- package/dist/cli/call.js +387 -0
- package/dist/cli/capabilities.d.ts +30 -0
- package/dist/cli/capabilities.js +197 -0
- package/dist/cli/draft.d.ts +7 -0
- package/dist/cli/draft.js +87 -0
- package/dist/cli/enrich.d.ts +8 -0
- package/dist/cli/enrich.js +806 -0
- package/dist/cli/fix.d.ts +33 -0
- package/dist/cli/fix.js +346 -0
- package/dist/cli/help.d.ts +25 -0
- package/dist/cli/help.js +646 -0
- package/dist/cli/icp.d.ts +7 -0
- package/dist/cli/icp.js +229 -0
- package/dist/cli/init.d.ts +7 -0
- package/dist/cli/init.js +58 -0
- package/dist/cli/market.d.ts +1 -0
- package/dist/cli/market.js +391 -0
- package/dist/cli/plans.d.ts +5 -0
- package/dist/cli/plans.js +456 -0
- package/dist/cli/schedule.d.ts +8 -0
- package/dist/cli/schedule.js +479 -0
- package/dist/cli/shared.d.ts +71 -0
- package/dist/cli/shared.js +342 -0
- package/dist/cli/signals.d.ts +7 -0
- package/dist/cli/signals.js +412 -0
- package/dist/cli/suggest.d.ts +49 -0
- package/dist/cli/suggest.js +135 -0
- package/dist/cli/tam.d.ts +9 -0
- package/dist/cli/tam.js +387 -0
- package/dist/cli/ui.d.ts +142 -0
- package/dist/cli/ui.js +427 -0
- package/dist/cli.d.ts +1 -57
- package/dist/cli.js +123 -5157
- package/dist/connector.d.ts +23 -0
- package/dist/connector.js +47 -0
- package/dist/connectors/hubspot.d.ts +10 -1
- package/dist/connectors/hubspot.js +244 -10
- package/dist/connectors/hubspotAuth.js +5 -1
- package/dist/connectors/linkedin.js +14 -14
- package/dist/connectors/salesforce.d.ts +10 -1
- package/dist/connectors/salesforce.js +39 -6
- package/dist/connectors/signalSources.js +7 -59
- package/dist/connectors/stripe.d.ts +37 -0
- package/dist/connectors/stripe.js +103 -31
- package/dist/enrich.d.ts +7 -0
- package/dist/enrich.js +7 -0
- package/dist/health.d.ts +11 -69
- package/dist/health.js +4 -134
- package/dist/healthScore.d.ts +71 -0
- package/dist/healthScore.js +143 -0
- package/dist/icp.d.ts +15 -11
- package/dist/icp.js +19 -36
- package/dist/index.d.ts +3 -1
- package/dist/index.js +3 -1
- package/dist/judge.d.ts +2 -0
- package/dist/judge.js +6 -0
- package/dist/llm.d.ts +29 -0
- package/dist/llm.js +206 -0
- package/dist/market.d.ts +39 -1
- package/dist/market.js +116 -44
- package/dist/marketClassify.d.ts +11 -1
- package/dist/marketClassify.js +17 -2
- package/dist/marketTaxonomy.d.ts +6 -1
- package/dist/marketTaxonomy.js +4 -2
- package/dist/mcp-bin.js +31 -2
- package/dist/mcp.js +372 -166
- package/dist/progress.d.ts +96 -0
- package/dist/progress.js +142 -0
- package/dist/runReport.d.ts +24 -0
- package/dist/runReport.js +139 -4
- package/dist/schedule.d.ts +80 -2
- package/dist/schedule.js +254 -1
- package/dist/spoolFiles.d.ts +44 -0
- package/dist/spoolFiles.js +114 -0
- package/dist/types.d.ts +29 -1
- package/docs/api.md +75 -8
- package/docs/architecture.md +2 -0
- package/docs/linkedin-connector-spec.md +1 -1
- package/docs/signal-spool-format.md +13 -0
- package/llms.txt +18 -9
- package/package.json +10 -3
- package/skills/fullstackgtm/SKILL.md +2 -1
- package/src/audit.ts +27 -1
- package/src/backfill.ts +340 -0
- package/src/bin.ts +5 -1
- package/src/cli/audit.ts +447 -0
- package/src/cli/auth.ts +669 -0
- package/src/cli/backfill.ts +156 -0
- package/src/cli/backfillRuns.ts +198 -0
- package/src/cli/call.ts +414 -0
- package/src/cli/capabilities.ts +215 -0
- package/src/cli/draft.ts +101 -0
- package/src/cli/enrich.ts +908 -0
- package/src/cli/fix.ts +373 -0
- package/src/cli/help.ts +702 -0
- package/src/cli/icp.ts +265 -0
- package/src/cli/init.ts +65 -0
- package/src/cli/market.ts +423 -0
- package/src/cli/plans.ts +524 -0
- package/src/cli/schedule.ts +526 -0
- package/src/cli/shared.ts +392 -0
- package/src/cli/signals.ts +434 -0
- package/src/cli/suggest.ts +151 -0
- package/src/cli/tam.ts +435 -0
- package/src/cli/ui.ts +497 -0
- package/src/cli.ts +122 -5855
- package/src/connector.ts +66 -0
- package/src/connectors/hubspot.ts +273 -9
- package/src/connectors/hubspotAuth.ts +5 -1
- package/src/connectors/linkedin.ts +14 -14
- package/src/connectors/salesforce.ts +51 -3
- package/src/connectors/signalSources.ts +7 -56
- package/src/connectors/stripe.ts +154 -34
- package/src/enrich.ts +13 -0
- package/src/health.ts +14 -213
- package/src/healthScore.ts +223 -0
- package/src/icp.ts +19 -35
- package/src/index.ts +28 -1
- package/src/judge.ts +7 -0
- package/src/llm.ts +239 -0
- package/src/market.ts +157 -44
- package/src/marketClassify.ts +26 -2
- package/src/marketTaxonomy.ts +10 -2
- package/src/mcp-bin.ts +34 -2
- package/src/mcp.ts +270 -63
- package/src/progress.ts +197 -0
- package/src/runReport.ts +159 -4
- package/src/schedule.ts +310 -3
- package/src/spoolFiles.ts +116 -0
- package/src/types.ts +32 -2
- package/docs/dx-punch-list.md +0 -87
- package/docs/roadmap-to-1.0.md +0 -211
package/dist/connector.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type ProgressEmitter } from "./progress.ts";
|
|
1
2
|
import type { GtmConnector, PatchPlan, PatchPlanRun } from "./types.ts";
|
|
2
3
|
export type ApplyPatchPlanOptions = {
|
|
3
4
|
/**
|
|
@@ -24,6 +25,28 @@ export type ApplyPatchPlanOptions = {
|
|
|
24
25
|
* edited mid-apply is conflicted out instead of overwritten. Default 25.
|
|
25
26
|
*/
|
|
26
27
|
recheckEvery?: number;
|
|
28
|
+
/**
|
|
29
|
+
* Per-operation progress as the run executes (presentation only — a
|
|
30
|
+
* throwing callback never affects the run). `completed` counts every
|
|
31
|
+
* resolved operation including skips and conflicts; `total` is the plan's
|
|
32
|
+
* full operation count.
|
|
33
|
+
*/
|
|
34
|
+
onOperation?: (progress: ApplyProgress) => void;
|
|
35
|
+
/**
|
|
36
|
+
* Shared progress vocabulary (src/progress.ts): the run emits the
|
|
37
|
+
* APPLY_STAGES (preflight → operations → results), one `opResult` per
|
|
38
|
+
* resolved operation (id + status only — no values), and `items` over the
|
|
39
|
+
* plan's operations. Additive alongside `onOperation`; presentation only.
|
|
40
|
+
*/
|
|
41
|
+
progress?: ProgressEmitter;
|
|
42
|
+
};
|
|
43
|
+
export type ApplyProgress = {
|
|
44
|
+
completed: number;
|
|
45
|
+
total: number;
|
|
46
|
+
applied: number;
|
|
47
|
+
failed: number;
|
|
48
|
+
conflicts: number;
|
|
49
|
+
skipped: number;
|
|
27
50
|
};
|
|
28
51
|
/**
|
|
29
52
|
* Apply an approved subset of a patch plan through a connector.
|
package/dist/connector.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { dedupeKey } from "./dedupe.js";
|
|
2
|
+
import { APPLY_STAGES } from "./progress.js";
|
|
2
3
|
import { requiresHumanInput } from "./rules.js";
|
|
3
4
|
const IRREVERSIBLE_OPERATIONS = new Set(["merge_records", "archive_record"]);
|
|
4
5
|
const IDENTITY_KEY_BY_TYPE = {
|
|
@@ -83,6 +84,8 @@ export async function applyPatchPlan(connector, plan, options) {
|
|
|
83
84
|
throw new Error(`The ${connector.provider} connector is read-only.`);
|
|
84
85
|
}
|
|
85
86
|
const startedAt = new Date().toISOString();
|
|
87
|
+
const emitStage = (stage) => options.progress?.stage(stage, APPLY_STAGES.indexOf(stage), APPLY_STAGES.length);
|
|
88
|
+
emitStage("preflight");
|
|
86
89
|
const approved = new Set(options.approvedOperationIds);
|
|
87
90
|
const checkConflicts = options.checkConflicts ?? typeof connector.readField === "function";
|
|
88
91
|
const results = [];
|
|
@@ -157,6 +160,7 @@ export async function applyPatchPlan(connector, plan, options) {
|
|
|
157
160
|
: "Operation was not approved.",
|
|
158
161
|
});
|
|
159
162
|
}
|
|
163
|
+
emitStage("results");
|
|
160
164
|
return {
|
|
161
165
|
planId: plan.id,
|
|
162
166
|
provider: connector.provider,
|
|
@@ -253,7 +257,48 @@ export async function applyPatchPlan(connector, plan, options) {
|
|
|
253
257
|
batched.set(result.operationId, result);
|
|
254
258
|
}
|
|
255
259
|
}
|
|
260
|
+
// Progress ticker state: every loop iteration below pushes at most one
|
|
261
|
+
// result; `lastNotified` guards the rare early-throw path so an iteration
|
|
262
|
+
// that pushed nothing reports nothing.
|
|
263
|
+
const progressCounts = { applied: 0, failed: 0, conflicts: 0, skipped: 0 };
|
|
264
|
+
const resultsBefore = results.length;
|
|
265
|
+
let lastNotified = results.length;
|
|
266
|
+
const notifyProgress = () => {
|
|
267
|
+
if ((!options.onOperation && !options.progress) || results.length === lastNotified)
|
|
268
|
+
return;
|
|
269
|
+
lastNotified = results.length;
|
|
270
|
+
const last = results[results.length - 1];
|
|
271
|
+
if (last.status === "applied")
|
|
272
|
+
progressCounts.applied += 1;
|
|
273
|
+
else if (last.status === "failed")
|
|
274
|
+
progressCounts.failed += 1;
|
|
275
|
+
else if (last.status === "conflict")
|
|
276
|
+
progressCounts.conflicts += 1;
|
|
277
|
+
else
|
|
278
|
+
progressCounts.skipped += 1;
|
|
279
|
+
// Shared vocabulary: operation id + status only — a conflict/failure
|
|
280
|
+
// detail can echo field values, which never leave the machine.
|
|
281
|
+
options.progress?.opResult(last.operationId, last.status);
|
|
282
|
+
options.progress?.items(results.length - resultsBefore, plan.operations.length);
|
|
283
|
+
if (options.onOperation) {
|
|
284
|
+
try {
|
|
285
|
+
options.onOperation({
|
|
286
|
+
completed: results.length - resultsBefore,
|
|
287
|
+
total: plan.operations.length,
|
|
288
|
+
...progressCounts,
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
catch {
|
|
292
|
+
// progress is presentation-only
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
};
|
|
296
|
+
emitStage("operations");
|
|
256
297
|
for (const operation of plan.operations) {
|
|
298
|
+
// Report the previous iteration's result (guarded: no-op if it pushed
|
|
299
|
+
// nothing). One-iteration lag keeps this a two-line hook instead of a
|
|
300
|
+
// notify after all ten push sites; the loop-exit call below flushes the last.
|
|
301
|
+
notifyProgress();
|
|
257
302
|
const batchedResult = batched.get(operation.id);
|
|
258
303
|
if (batchedResult) {
|
|
259
304
|
results.push(batchedResult);
|
|
@@ -342,6 +387,8 @@ export async function applyPatchPlan(connector, plan, options) {
|
|
|
342
387
|
});
|
|
343
388
|
}
|
|
344
389
|
}
|
|
390
|
+
notifyProgress();
|
|
391
|
+
emitStage("results");
|
|
345
392
|
return {
|
|
346
393
|
planId: plan.id,
|
|
347
394
|
provider: connector.provider,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type FieldMappings } from "../mappings.ts";
|
|
2
|
-
import type { GtmConnector } from "../types.ts";
|
|
2
|
+
import type { GtmConnector, SnapshotProgress } from "../types.ts";
|
|
3
|
+
import { type ProgressEmitter } from "../progress.ts";
|
|
3
4
|
export type HubspotConnectorOptions = {
|
|
4
5
|
/** Returns a HubSpot access token (private app token or OAuth access token). */
|
|
5
6
|
getAccessToken: () => string | Promise<string>;
|
|
@@ -8,6 +9,14 @@ export type HubspotConnectorOptions = {
|
|
|
8
9
|
apiBaseUrl?: string;
|
|
9
10
|
/** Injectable fetch for testing. */
|
|
10
11
|
fetchImpl?: typeof fetch;
|
|
12
|
+
/** Per-page snapshot-pull progress (presentation only — errors are swallowed). */
|
|
13
|
+
onProgress?: (progress: SnapshotProgress) => void;
|
|
14
|
+
/**
|
|
15
|
+
* Shared progress vocabulary (src/progress.ts): the snapshot pull emits a
|
|
16
|
+
* `stage` per object type and an `items` heartbeat per page. Additive
|
|
17
|
+
* alongside the legacy `onProgress` callback; both are presentation-only.
|
|
18
|
+
*/
|
|
19
|
+
progress?: ProgressEmitter;
|
|
11
20
|
};
|
|
12
21
|
/**
|
|
13
22
|
* Reference connector for HubSpot.
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { HUBSPOT_DEFAULT_FIELD_MAPPINGS, mappedField, mappedFields, readMappedValue, } from "../mappings.js";
|
|
2
|
+
import { SNAPSHOT_PULL_STAGES } from "../progress.js";
|
|
2
3
|
const DEFAULT_API_BASE_URL = "https://api.hubapi.com";
|
|
3
4
|
const OBJECT_PATHS = {
|
|
4
5
|
account: "companies",
|
|
@@ -10,6 +11,14 @@ const MAPPING_OBJECT_TYPES = {
|
|
|
10
11
|
contact: "contacts",
|
|
11
12
|
deal: "deals",
|
|
12
13
|
};
|
|
14
|
+
// SnapshotProgress object types → the shared SNAPSHOT_PULL_STAGES names, so
|
|
15
|
+
// the CLI checklist and the app StageTimeline read the same stage labels.
|
|
16
|
+
const PULL_STAGE_BY_TYPE = {
|
|
17
|
+
user: "owners",
|
|
18
|
+
account: "accounts",
|
|
19
|
+
contact: "contacts",
|
|
20
|
+
deal: "deals",
|
|
21
|
+
};
|
|
13
22
|
/**
|
|
14
23
|
* Reference connector for HubSpot.
|
|
15
24
|
*
|
|
@@ -30,6 +39,32 @@ export function createHubspotConnector(options) {
|
|
|
30
39
|
// match value (email): HubSpot search is eventually consistent, so a contact
|
|
31
40
|
// created earlier in this apply run is invisible to a later search.
|
|
32
41
|
const createdContactsByMatch = new Map();
|
|
42
|
+
// Same-run dedup for `create_record` deal creates, keyed by
|
|
43
|
+
// matchKey:matchValue (e.g. stripe_invoice_id:in_123) — same
|
|
44
|
+
// eventual-consistency rationale as the contact cache.
|
|
45
|
+
const createdDealsByMatch = new Map();
|
|
46
|
+
// One /crm/v3/pipelines/deals read per connector lifetime (one apply run):
|
|
47
|
+
// pipeline/stage metadata doesn't change mid-run, and deal creates resolve
|
|
48
|
+
// the closed-won stage from it per operation.
|
|
49
|
+
let dealPipelinesCache;
|
|
50
|
+
// Custom deal dedupe properties ensured (or confirmed missing) this run.
|
|
51
|
+
const ensuredDealProperties = new Map();
|
|
52
|
+
// Per-page snapshot-pull progress: the legacy onProgress callback plus the
|
|
53
|
+
// shared emitter (stage on the first page of each object type, items per
|
|
54
|
+
// page). Both are presentation-only — callers already swallow errors.
|
|
55
|
+
let lastPullStage;
|
|
56
|
+
const pullProgress = (objectType, fetched) => {
|
|
57
|
+
options.onProgress?.({ objectType, fetched });
|
|
58
|
+
const emitter = options.progress;
|
|
59
|
+
if (!emitter)
|
|
60
|
+
return;
|
|
61
|
+
const stage = PULL_STAGE_BY_TYPE[objectType];
|
|
62
|
+
if (stage !== lastPullStage) {
|
|
63
|
+
lastPullStage = stage;
|
|
64
|
+
emitter.stage(stage, SNAPSHOT_PULL_STAGES.indexOf(stage), SNAPSHOT_PULL_STAGES.length);
|
|
65
|
+
}
|
|
66
|
+
emitter.items(fetched);
|
|
67
|
+
};
|
|
33
68
|
async function request(path, init = {}) {
|
|
34
69
|
const token = await options.getAccessToken();
|
|
35
70
|
let response;
|
|
@@ -89,7 +124,7 @@ export function createHubspotConnector(options) {
|
|
|
89
124
|
}
|
|
90
125
|
return map;
|
|
91
126
|
}
|
|
92
|
-
async function list(path) {
|
|
127
|
+
async function list(path, onPage) {
|
|
93
128
|
const results = [];
|
|
94
129
|
let after;
|
|
95
130
|
const seen = new Set();
|
|
@@ -104,12 +139,18 @@ export function createHubspotConnector(options) {
|
|
|
104
139
|
const separator = path.includes("?") ? "&" : "?";
|
|
105
140
|
const data = await request(`${path}${after ? `${separator}after=${encodeURIComponent(after)}` : ""}`);
|
|
106
141
|
results.push(...(data.results ?? []));
|
|
142
|
+
try {
|
|
143
|
+
onPage?.(results.length);
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
// progress is presentation-only; never let it fail a pull
|
|
147
|
+
}
|
|
107
148
|
after = data.paging?.next?.after;
|
|
108
149
|
} while (after);
|
|
109
150
|
return results;
|
|
110
151
|
}
|
|
111
152
|
async function assembleSnapshot(fetchObjects) {
|
|
112
|
-
const owners = await list("/crm/v3/owners?limit=100");
|
|
153
|
+
const owners = await list("/crm/v3/owners?limit=100", (fetched) => pullProgress("user", fetched));
|
|
113
154
|
const users = owners
|
|
114
155
|
.filter((owner) => owner.id)
|
|
115
156
|
.map((owner) => ({
|
|
@@ -222,6 +263,8 @@ export function createHubspotConnector(options) {
|
|
|
222
263
|
raw: deal,
|
|
223
264
|
};
|
|
224
265
|
});
|
|
266
|
+
// Deliver any throttled trailing items heartbeat before the pull returns.
|
|
267
|
+
options.progress?.flush();
|
|
225
268
|
return {
|
|
226
269
|
generatedAt: new Date().toISOString(),
|
|
227
270
|
provider: "hubspot",
|
|
@@ -235,7 +278,8 @@ export function createHubspotConnector(options) {
|
|
|
235
278
|
};
|
|
236
279
|
}
|
|
237
280
|
async function fetchSnapshot() {
|
|
238
|
-
|
|
281
|
+
const canonicalType = { companies: "account", contacts: "contact", deals: "deal" };
|
|
282
|
+
return assembleSnapshot((objectType, properties, withAssociations) => list(`/crm/v3/objects/${objectType}?limit=100&properties=${properties}${withAssociations ? "&associations=companies" : ""}`, (fetched) => pullProgress(canonicalType[objectType], fetched)));
|
|
239
283
|
}
|
|
240
284
|
const MODIFIED_DATE_PROPERTIES = {
|
|
241
285
|
companies: "hs_lastmodifieddate",
|
|
@@ -499,12 +543,199 @@ export function createHubspotConnector(options) {
|
|
|
499
543
|
createdCompaniesByName.set(cacheKey, id);
|
|
500
544
|
return id;
|
|
501
545
|
}
|
|
546
|
+
/** Exact-value deal lookup (by any searchable property) for resolve-first creates. */
|
|
547
|
+
async function searchDealsBy(property, value) {
|
|
548
|
+
const data = await request(`/crm/v3/objects/deals/search`, {
|
|
549
|
+
method: "POST",
|
|
550
|
+
body: JSON.stringify({
|
|
551
|
+
filterGroups: [{ filters: [{ propertyName: property, operator: "EQ", value }] }],
|
|
552
|
+
properties: [property],
|
|
553
|
+
limit: 3,
|
|
554
|
+
}),
|
|
555
|
+
});
|
|
556
|
+
return (data?.results ?? []).map((row) => String(row.id));
|
|
557
|
+
}
|
|
558
|
+
/**
|
|
559
|
+
* Make sure the deal dedupe property (e.g. "stripe_invoice_id") exists so
|
|
560
|
+
* both the resolve-first search and the stamped value are real. Best-effort
|
|
561
|
+
* create on a confirmed miss; returns false when the property can neither
|
|
562
|
+
* be read nor created — the caller must SKIP then (creating a deal without
|
|
563
|
+
* its dedupe key would make every replay a duplicate).
|
|
564
|
+
*/
|
|
565
|
+
async function ensureDealProperty(name) {
|
|
566
|
+
const cached = ensuredDealProperties.get(name);
|
|
567
|
+
if (cached !== undefined)
|
|
568
|
+
return cached;
|
|
569
|
+
let ok;
|
|
570
|
+
try {
|
|
571
|
+
await request(`/crm/v3/properties/deals/${encodeURIComponent(name)}`);
|
|
572
|
+
ok = true;
|
|
573
|
+
}
|
|
574
|
+
catch {
|
|
575
|
+
// Missing (404) or unreadable — try to create it; a failure here means
|
|
576
|
+
// we cannot guarantee the dedupe key, so report false.
|
|
577
|
+
try {
|
|
578
|
+
await request(`/crm/v3/properties/deals`, {
|
|
579
|
+
method: "POST",
|
|
580
|
+
body: JSON.stringify({
|
|
581
|
+
name,
|
|
582
|
+
label: name,
|
|
583
|
+
type: "string",
|
|
584
|
+
fieldType: "text",
|
|
585
|
+
groupName: "dealinformation",
|
|
586
|
+
hasUniqueValue: false,
|
|
587
|
+
}),
|
|
588
|
+
});
|
|
589
|
+
ok = true;
|
|
590
|
+
}
|
|
591
|
+
catch {
|
|
592
|
+
ok = false;
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
ensuredDealProperties.set(name, ok);
|
|
596
|
+
return ok;
|
|
597
|
+
}
|
|
502
598
|
/**
|
|
503
|
-
*
|
|
504
|
-
*
|
|
505
|
-
*
|
|
506
|
-
*
|
|
507
|
-
*
|
|
599
|
+
* Resolve the target pipeline and its closed-won stage from the portal's
|
|
600
|
+
* own pipeline metadata (one read per run). The pipeline is picked by id or
|
|
601
|
+
* case-insensitive label (`hint`), else the default (lowest displayOrder);
|
|
602
|
+
* the closed-won stage is the one whose metadata says isClosed with
|
|
603
|
+
* probability 1 — NEVER a substring guess against stage names. Returns null
|
|
604
|
+
* when either is unresolvable so the caller skips instead of guessing.
|
|
605
|
+
*/
|
|
606
|
+
async function resolveClosedWonStage(hint) {
|
|
607
|
+
if (!dealPipelinesCache) {
|
|
608
|
+
dealPipelinesCache = request("/crm/v3/pipelines/deals").then((data) => (data?.results ?? []));
|
|
609
|
+
// A failed read must not poison the cache for the rest of the run.
|
|
610
|
+
dealPipelinesCache = dealPipelinesCache.catch(() => []);
|
|
611
|
+
}
|
|
612
|
+
const pipelines = await dealPipelinesCache;
|
|
613
|
+
if (pipelines.length === 0)
|
|
614
|
+
return null;
|
|
615
|
+
let pipeline;
|
|
616
|
+
if (hint) {
|
|
617
|
+
const wanted = hint.trim().toLowerCase();
|
|
618
|
+
pipeline = pipelines.find((candidate) => String(candidate?.id ?? "").toLowerCase() === wanted ||
|
|
619
|
+
String(candidate?.label ?? "").trim().toLowerCase() === wanted);
|
|
620
|
+
if (!pipeline)
|
|
621
|
+
return null; // an explicit hint that matches nothing is an error, not "use default"
|
|
622
|
+
}
|
|
623
|
+
else {
|
|
624
|
+
pipeline = [...pipelines].sort((a, b) => Number(a?.displayOrder ?? 0) - Number(b?.displayOrder ?? 0))[0];
|
|
625
|
+
}
|
|
626
|
+
if (!pipeline?.id)
|
|
627
|
+
return null;
|
|
628
|
+
const stage = (pipeline.stages ?? []).find((candidate) => {
|
|
629
|
+
const meta = candidate?.metadata ?? {};
|
|
630
|
+
return (candidate?.id &&
|
|
631
|
+
String(meta.isClosed).toLowerCase() === "true" &&
|
|
632
|
+
Number(meta.probability) === 1);
|
|
633
|
+
});
|
|
634
|
+
if (!stage)
|
|
635
|
+
return null;
|
|
636
|
+
return { pipelineId: String(pipeline.id), stageId: String(stage.id) };
|
|
637
|
+
}
|
|
638
|
+
/**
|
|
639
|
+
* Deal branch of create_record (backfill: one closed-won deal per paid
|
|
640
|
+
* invoice). Resolve-first on the custom dedupe property (ensured to exist
|
|
641
|
+
* first), stage resolved from pipeline metadata, company association
|
|
642
|
+
* best-effort — same contract as the contact branch.
|
|
643
|
+
*/
|
|
644
|
+
async function createDealRecord(operation, payload, matchValue) {
|
|
645
|
+
const matchKey = String(payload.matchKey ?? "").trim();
|
|
646
|
+
if (!matchKey) {
|
|
647
|
+
return { operationId: operation.id, status: "skipped", detail: "create_record for deals needs a matchKey (the dedupe property, e.g. stripe_invoice_id)." };
|
|
648
|
+
}
|
|
649
|
+
const dedupeKey = `${matchKey}:${matchValue.toLowerCase()}`;
|
|
650
|
+
const alreadyCreated = createdDealsByMatch.get(dedupeKey);
|
|
651
|
+
if (alreadyCreated) {
|
|
652
|
+
return { operationId: operation.id, status: "skipped", detail: `Deal ${matchKey}=${matchValue} already created earlier in this run; not duplicating.`, providerData: { id: alreadyCreated, existing: true } };
|
|
653
|
+
}
|
|
654
|
+
// The dedupe property is usually CUSTOM — ensure it exists before the
|
|
655
|
+
// search; searching a property HubSpot doesn't have 400s, and creating a
|
|
656
|
+
// deal without the key stamped would defeat every future resolve-first.
|
|
657
|
+
const propertyOk = await ensureDealProperty(matchKey);
|
|
658
|
+
if (!propertyOk) {
|
|
659
|
+
return { operationId: operation.id, status: "skipped", detail: `Deal dedupe property "${matchKey}" does not exist and could not be created; refusing to create a deal without its dedupe key.` };
|
|
660
|
+
}
|
|
661
|
+
const existing = await searchDealsBy(matchKey, matchValue);
|
|
662
|
+
if (existing.length > 0) {
|
|
663
|
+
createdDealsByMatch.set(dedupeKey, existing[0]);
|
|
664
|
+
return { operationId: operation.id, status: "skipped", detail: `Deal ${matchKey}=${matchValue} already exists (${existing.join(", ")}); resolve-first declined to create.`, providerData: { id: existing[0], existing: true } };
|
|
665
|
+
}
|
|
666
|
+
// Stage: only the provider-neutral "closed_won" sentinel is understood,
|
|
667
|
+
// and it must resolve to a real stage id from pipeline metadata.
|
|
668
|
+
if (payload.dealStage !== "closed_won") {
|
|
669
|
+
return { operationId: operation.id, status: "skipped", detail: 'create_record for deals needs dealStage "closed_won" (the connector resolves the real stage id from pipeline metadata).' };
|
|
670
|
+
}
|
|
671
|
+
const resolved = await resolveClosedWonStage(payload.dealPipeline);
|
|
672
|
+
if (!resolved) {
|
|
673
|
+
return {
|
|
674
|
+
operationId: operation.id,
|
|
675
|
+
status: "skipped",
|
|
676
|
+
detail: payload.dealPipeline
|
|
677
|
+
? `Could not resolve pipeline "${payload.dealPipeline}" (by id or label) to a closed-won stage; not guessing a stage.`
|
|
678
|
+
: "Could not resolve the default deal pipeline's closed-won stage (isClosed + probability 1) from pipeline metadata; not guessing a stage.",
|
|
679
|
+
};
|
|
680
|
+
}
|
|
681
|
+
const properties = {
|
|
682
|
+
...payload.properties,
|
|
683
|
+
[matchKey]: matchValue,
|
|
684
|
+
pipeline: resolved.pipelineId,
|
|
685
|
+
dealstage: resolved.stageId,
|
|
686
|
+
};
|
|
687
|
+
if (payload.ownerId && !properties.hubspot_owner_id) {
|
|
688
|
+
properties.hubspot_owner_id = String(payload.ownerId);
|
|
689
|
+
}
|
|
690
|
+
let created;
|
|
691
|
+
try {
|
|
692
|
+
created = await request(`/crm/v3/objects/deals`, {
|
|
693
|
+
method: "POST",
|
|
694
|
+
body: JSON.stringify({ properties: { ...properties, hs_object_source_detail_2: `fullstackgtm backfill (${operation.id})` } }),
|
|
695
|
+
});
|
|
696
|
+
}
|
|
697
|
+
catch {
|
|
698
|
+
// Some portals reject writes to source-detail properties — the provenance
|
|
699
|
+
// stamp is best-effort, the create is not.
|
|
700
|
+
created = await request(`/crm/v3/objects/deals`, {
|
|
701
|
+
method: "POST",
|
|
702
|
+
body: JSON.stringify({ properties }),
|
|
703
|
+
});
|
|
704
|
+
}
|
|
705
|
+
const dealId = String(created.id);
|
|
706
|
+
createdDealsByMatch.set(dedupeKey, dealId);
|
|
707
|
+
let companyNote = "";
|
|
708
|
+
if (payload.associateCompanyName) {
|
|
709
|
+
try {
|
|
710
|
+
const companyId = await resolveOrCreateCompany(payload.associateCompanyName, payload.associateCompanyDomain, operation.id);
|
|
711
|
+
if (companyId) {
|
|
712
|
+
await request(`/crm/v4/objects/deals/${encodeURIComponent(dealId)}/associations/default/companies/${encodeURIComponent(companyId)}`, { method: "PUT" });
|
|
713
|
+
const domainNote = payload.associateCompanyDomain ? ` (${payload.associateCompanyDomain})` : "";
|
|
714
|
+
companyNote = ` Linked to company "${payload.associateCompanyName}"${domainNote} (${companyId}).`;
|
|
715
|
+
}
|
|
716
|
+
else {
|
|
717
|
+
companyNote = ` Company "${payload.associateCompanyName}" was ambiguous; left unlinked.`;
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
catch (error) {
|
|
721
|
+
// Association is best-effort — the deal create already succeeded.
|
|
722
|
+
companyNote = ` (company link failed: ${error instanceof Error ? error.message : String(error)})`;
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
return {
|
|
726
|
+
operationId: operation.id,
|
|
727
|
+
status: "applied",
|
|
728
|
+
detail: `Created deal ${matchKey}=${matchValue} (${dealId}) in stage ${resolved.stageId}.${companyNote}`,
|
|
729
|
+
providerData: { id: dealId, created: true, pipelineId: resolved.pipelineId, stageId: resolved.stageId },
|
|
730
|
+
};
|
|
731
|
+
}
|
|
732
|
+
/**
|
|
733
|
+
* Create a NET-NEW record (a sourced lead, or a backfilled deal).
|
|
734
|
+
* Resolve-first: re-checks the dedupe key against the live CRM (the
|
|
735
|
+
* plan-time snapshot can be stale) and creates ONLY on a confirmed miss — a
|
|
736
|
+
* record a concurrent writer already added is returned as `skipped`, never
|
|
737
|
+
* duplicated. Contacts and deals can be linked to a resolved-or-created
|
|
738
|
+
* company in the same step.
|
|
508
739
|
*/
|
|
509
740
|
async function createRecord(operation) {
|
|
510
741
|
const payload = operation.afterValue;
|
|
@@ -512,13 +743,16 @@ export function createHubspotConnector(options) {
|
|
|
512
743
|
return { operationId: operation.id, status: "skipped", detail: "create_record needs a CreateRecordPayload afterValue." };
|
|
513
744
|
}
|
|
514
745
|
const objectPath = OBJECT_PATHS[operation.objectType];
|
|
515
|
-
if (operation.objectType !== "contact" && operation.objectType !== "account") {
|
|
516
|
-
return { operationId: operation.id, status: "skipped", detail: "create_record supports contacts and
|
|
746
|
+
if (operation.objectType !== "contact" && operation.objectType !== "account" && operation.objectType !== "deal") {
|
|
747
|
+
return { operationId: operation.id, status: "skipped", detail: "create_record supports contacts, accounts, and deals." };
|
|
517
748
|
}
|
|
518
749
|
const matchValue = String(payload.matchValue ?? "").trim();
|
|
519
750
|
if (!matchValue) {
|
|
520
751
|
return { operationId: operation.id, status: "skipped", detail: "create_record needs a non-empty matchValue to resolve-first." };
|
|
521
752
|
}
|
|
753
|
+
if (operation.objectType === "deal") {
|
|
754
|
+
return createDealRecord(operation, payload, matchValue);
|
|
755
|
+
}
|
|
522
756
|
if (operation.objectType === "account") {
|
|
523
757
|
const id = await resolveOrCreateCompany(matchValue, stringOrUndefined(payload.properties?.domain), operation.id);
|
|
524
758
|
if (id === null) {
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { createServer } from "node:http";
|
|
2
1
|
import { randomBytes } from "node:crypto";
|
|
3
2
|
/**
|
|
4
3
|
* HubSpot CLI authentication.
|
|
@@ -101,6 +100,11 @@ export async function runHubspotLoopbackLogin(options) {
|
|
|
101
100
|
const log = options.log ?? ((message) => console.error(message));
|
|
102
101
|
const redirectUri = `http://localhost:${port}/callback`;
|
|
103
102
|
const state = randomBytes(16).toString("hex");
|
|
103
|
+
// Deferred: `node:http` transitively compiles node's bundled undici on
|
|
104
|
+
// import (~10ms+, 12% of a demo-audit run's CPU profile). This module is in
|
|
105
|
+
// the eager graph of every CLI invocation via help.ts/auth.ts, but the
|
|
106
|
+
// server is only needed during the interactive OAuth loopback login.
|
|
107
|
+
const { createServer } = await import("node:http");
|
|
104
108
|
const code = await new Promise((resolve, reject) => {
|
|
105
109
|
const server = createServer((request, response) => {
|
|
106
110
|
const url = new URL(request.url ?? "/", `http://localhost:${port}`);
|
|
@@ -146,37 +146,37 @@ export function normalizeHeyReachLead(raw) {
|
|
|
146
146
|
// ── Fake provider (tests / dry-run with no key, no network) ─────────────────
|
|
147
147
|
export const FAKE_LINKEDIN_PROSPECTS = [
|
|
148
148
|
{
|
|
149
|
-
firstName: "
|
|
150
|
-
lastName: "
|
|
151
|
-
fullName: "
|
|
149
|
+
firstName: "Example",
|
|
150
|
+
lastName: "ProspectAlpha",
|
|
151
|
+
fullName: "Example Prospect Alpha",
|
|
152
152
|
headline: "VP RevOps",
|
|
153
153
|
jobTitle: "VP Revenue Operations",
|
|
154
154
|
company: "Northwind Logistics",
|
|
155
|
-
profileUrl: "https://
|
|
155
|
+
profileUrl: "https://example.com/linkedin/example-prospect-alpha",
|
|
156
156
|
location: "Chicago, IL",
|
|
157
|
-
email: "
|
|
157
|
+
email: "prospect.alpha@example.com",
|
|
158
158
|
emailStatus: "verified",
|
|
159
159
|
},
|
|
160
160
|
{
|
|
161
|
-
firstName: "
|
|
162
|
-
lastName: "
|
|
163
|
-
fullName: "
|
|
161
|
+
firstName: "Example",
|
|
162
|
+
lastName: "ProspectBeta",
|
|
163
|
+
fullName: "Example Prospect Beta",
|
|
164
164
|
headline: "Head of Sales Ops",
|
|
165
165
|
jobTitle: "Head of Sales Operations",
|
|
166
166
|
company: "Cobalt Health",
|
|
167
|
-
profileUrl: "https://
|
|
167
|
+
profileUrl: "https://example.com/linkedin/example-prospect-beta",
|
|
168
168
|
location: "Austin, TX",
|
|
169
169
|
},
|
|
170
170
|
{
|
|
171
|
-
firstName: "
|
|
172
|
-
lastName: "
|
|
173
|
-
fullName: "
|
|
171
|
+
firstName: "Example",
|
|
172
|
+
lastName: "ProspectGamma",
|
|
173
|
+
fullName: "Example Prospect Gamma",
|
|
174
174
|
headline: "RevOps Manager",
|
|
175
175
|
jobTitle: "Revenue Operations Manager",
|
|
176
176
|
company: "Ferro Systems",
|
|
177
|
-
profileUrl: "https://
|
|
177
|
+
profileUrl: "https://example.com/linkedin/example-prospect-gamma",
|
|
178
178
|
location: "Remote",
|
|
179
|
-
email: "
|
|
179
|
+
email: "prospect.gamma@example.com",
|
|
180
180
|
emailStatus: "guessed",
|
|
181
181
|
},
|
|
182
182
|
];
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type FieldMappings } from "../mappings.ts";
|
|
2
|
-
import type { GtmConnector } from "../types.ts";
|
|
2
|
+
import type { GtmConnector, SnapshotProgress } from "../types.ts";
|
|
3
|
+
import { type ProgressEmitter } from "../progress.ts";
|
|
3
4
|
export type SalesforceConnection = {
|
|
4
5
|
accessToken: string;
|
|
5
6
|
/** e.g. https://yourorg.my.salesforce.com */
|
|
@@ -13,6 +14,14 @@ export type SalesforceConnectorOptions = {
|
|
|
13
14
|
apiVersion?: string;
|
|
14
15
|
/** Injectable fetch for testing. */
|
|
15
16
|
fetchImpl?: typeof fetch;
|
|
17
|
+
/** Per-page snapshot-pull progress (presentation only — errors are swallowed). */
|
|
18
|
+
onProgress?: (progress: SnapshotProgress) => void;
|
|
19
|
+
/**
|
|
20
|
+
* Shared progress vocabulary (src/progress.ts): the snapshot pull emits a
|
|
21
|
+
* `stage` per object type and an `items` heartbeat per page. Additive
|
|
22
|
+
* alongside the legacy `onProgress` callback; both are presentation-only.
|
|
23
|
+
*/
|
|
24
|
+
progress?: ProgressEmitter;
|
|
16
25
|
};
|
|
17
26
|
/**
|
|
18
27
|
* Reference connector for Salesforce.
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { SALESFORCE_DEFAULT_FIELD_MAPPINGS, mappedField, mappedFields, readMappedValue, } from "../mappings.js";
|
|
2
|
+
import { SNAPSHOT_PULL_STAGES } from "../progress.js";
|
|
2
3
|
const DEFAULT_API_VERSION = "v59.0";
|
|
3
4
|
const SOBJECT_TYPES = {
|
|
4
5
|
account: "Account",
|
|
@@ -16,6 +17,14 @@ const MAPPING_OBJECT_TYPES = {
|
|
|
16
17
|
contact: "contacts",
|
|
17
18
|
deal: "deals",
|
|
18
19
|
};
|
|
20
|
+
// SnapshotProgress object types → the shared SNAPSHOT_PULL_STAGES names, so
|
|
21
|
+
// the CLI checklist and the app StageTimeline read the same stage labels.
|
|
22
|
+
const PULL_STAGE_BY_TYPE = {
|
|
23
|
+
user: "owners",
|
|
24
|
+
account: "accounts",
|
|
25
|
+
contact: "contacts",
|
|
26
|
+
deal: "deals",
|
|
27
|
+
};
|
|
19
28
|
/**
|
|
20
29
|
* Reference connector for Salesforce.
|
|
21
30
|
*
|
|
@@ -116,7 +125,7 @@ export function createSalesforceConnector(options) {
|
|
|
116
125
|
const text = await response.text();
|
|
117
126
|
return text ? JSON.parse(text) : null;
|
|
118
127
|
}
|
|
119
|
-
async function query(soql) {
|
|
128
|
+
async function query(soql, onPage) {
|
|
120
129
|
const records = [];
|
|
121
130
|
let next = `/services/data/${apiVersion}/query?q=${encodeURIComponent(soql)}`;
|
|
122
131
|
const seen = new Set();
|
|
@@ -127,6 +136,12 @@ export function createSalesforceConnector(options) {
|
|
|
127
136
|
seen.add(next);
|
|
128
137
|
const data = await request(next);
|
|
129
138
|
records.push(...(data?.records ?? []));
|
|
139
|
+
try {
|
|
140
|
+
onPage?.(records.length);
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
// progress is presentation-only; never let it fail a pull
|
|
144
|
+
}
|
|
130
145
|
next = data?.nextRecordsUrl ?? undefined;
|
|
131
146
|
}
|
|
132
147
|
return records;
|
|
@@ -138,7 +153,23 @@ export function createSalesforceConnector(options) {
|
|
|
138
153
|
return mappedFields(mappings, objectType, SALESFORCE_DEFAULT_FIELD_MAPPINGS[objectType]).join(", ");
|
|
139
154
|
}
|
|
140
155
|
async function assembleSnapshot(whereClause) {
|
|
141
|
-
|
|
156
|
+
// Per-page snapshot-pull progress: the legacy onProgress callback plus the
|
|
157
|
+
// shared emitter (stage on the first page of each object type, items per
|
|
158
|
+
// page). Both are presentation-only — `query` already swallows errors.
|
|
159
|
+
let lastPullStage;
|
|
160
|
+
const progressFor = (objectType) => (fetched) => {
|
|
161
|
+
options.onProgress?.({ objectType, fetched });
|
|
162
|
+
const emitter = options.progress;
|
|
163
|
+
if (!emitter)
|
|
164
|
+
return;
|
|
165
|
+
const stage = PULL_STAGE_BY_TYPE[objectType];
|
|
166
|
+
if (stage !== lastPullStage) {
|
|
167
|
+
lastPullStage = stage;
|
|
168
|
+
emitter.stage(stage, SNAPSHOT_PULL_STAGES.indexOf(stage), SNAPSHOT_PULL_STAGES.length);
|
|
169
|
+
}
|
|
170
|
+
emitter.items(fetched);
|
|
171
|
+
};
|
|
172
|
+
const sfUsers = await query(`SELECT ${selectFields("owners")} FROM User${whereClause}`, progressFor("user"));
|
|
142
173
|
const users = sfUsers.map((user) => {
|
|
143
174
|
const id = String(readMapped(user, "owners", "id", "Id"));
|
|
144
175
|
const email = stringOrUndefined(readMapped(user, "owners", "email", "Email"));
|
|
@@ -153,7 +184,7 @@ export function createSalesforceConnector(options) {
|
|
|
153
184
|
active: Boolean(readMapped(user, "owners", "isActive", "IsActive")),
|
|
154
185
|
};
|
|
155
186
|
});
|
|
156
|
-
const sfAccounts = await query(`SELECT ${selectFields("accounts")} FROM Account${whereClause}
|
|
187
|
+
const sfAccounts = await query(`SELECT ${selectFields("accounts")} FROM Account${whereClause}`, progressFor("account"));
|
|
157
188
|
const accounts = sfAccounts.map((account) => {
|
|
158
189
|
const id = String(readMapped(account, "accounts", "id", "Id"));
|
|
159
190
|
return {
|
|
@@ -170,7 +201,7 @@ export function createSalesforceConnector(options) {
|
|
|
170
201
|
raw: account,
|
|
171
202
|
};
|
|
172
203
|
});
|
|
173
|
-
const sfContacts = await query(`SELECT ${selectFields("contacts")} FROM Contact${whereClause}
|
|
204
|
+
const sfContacts = await query(`SELECT ${selectFields("contacts")} FROM Contact${whereClause}`, progressFor("contact"));
|
|
174
205
|
const contacts = sfContacts.map((contact) => {
|
|
175
206
|
const id = String(readMapped(contact, "contacts", "id", "Id"));
|
|
176
207
|
return {
|
|
@@ -188,7 +219,7 @@ export function createSalesforceConnector(options) {
|
|
|
188
219
|
raw: contact,
|
|
189
220
|
};
|
|
190
221
|
});
|
|
191
|
-
const sfOpportunities = await query(`SELECT ${selectFields("deals")} FROM Opportunity${whereClause}
|
|
222
|
+
const sfOpportunities = await query(`SELECT ${selectFields("deals")} FROM Opportunity${whereClause}`, progressFor("deal"));
|
|
192
223
|
const deals = sfOpportunities.map((opportunity) => {
|
|
193
224
|
const id = String(readMapped(opportunity, "deals", "id", "Id"));
|
|
194
225
|
const probability = numberOrUndefined(readMapped(opportunity, "deals", "probability", "Probability"));
|
|
@@ -228,6 +259,8 @@ export function createSalesforceConnector(options) {
|
|
|
228
259
|
raw: opportunity,
|
|
229
260
|
};
|
|
230
261
|
});
|
|
262
|
+
// Deliver any throttled trailing items heartbeat before the pull returns.
|
|
263
|
+
options.progress?.flush();
|
|
231
264
|
return {
|
|
232
265
|
generatedAt: new Date().toISOString(),
|
|
233
266
|
provider: "salesforce",
|
|
@@ -444,7 +477,7 @@ export function createSalesforceConnector(options) {
|
|
|
444
477
|
return { operationId: operation.id, status: "skipped", detail: "create_record needs a CreateRecordPayload afterValue." };
|
|
445
478
|
}
|
|
446
479
|
if (operation.objectType !== "contact" && operation.objectType !== "account") {
|
|
447
|
-
return { operationId: operation.id, status: "skipped", detail: "create_record supports contacts and accounts." };
|
|
480
|
+
return { operationId: operation.id, status: "skipped", detail: "create_record supports contacts and accounts on Salesforce (deal/Opportunity creation is HubSpot-only for now)." };
|
|
448
481
|
}
|
|
449
482
|
const matchValue = String(payload.matchValue ?? "").trim();
|
|
450
483
|
if (!matchValue) {
|