fullstackgtm 0.18.0 → 0.20.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 +78 -1
- package/dist/bulkUpdate.d.ts +37 -0
- package/dist/bulkUpdate.js +315 -0
- package/dist/cli.js +161 -2
- package/dist/connector.d.ts +6 -0
- package/dist/connector.js +158 -17
- package/dist/index.d.ts +5 -2
- package/dist/index.js +4 -1
- package/dist/market.d.ts +28 -0
- package/dist/market.js +3 -0
- package/dist/marketOverlay.d.ts +116 -0
- package/dist/marketOverlay.js +258 -0
- package/dist/marketReport.js +16 -3
- package/dist/marketScale.d.ts +42 -0
- package/dist/marketScale.js +68 -0
- package/dist/mcp.js +13 -2
- package/dist/types.d.ts +44 -0
- package/package.json +1 -1
- package/src/bulkUpdate.ts +375 -0
- package/src/cli.ts +183 -2
- package/src/connector.ts +169 -23
- package/src/index.ts +18 -0
- package/src/market.ts +32 -0
- package/src/marketOverlay.ts +410 -0
- package/src/marketReport.ts +20 -4
- package/src/marketScale.ts +111 -0
- package/src/mcp.ts +15 -2
- package/src/types.ts +39 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,83 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
|
5
5
|
and the project adheres to [Semantic Versioning](https://semver.org/).
|
|
6
6
|
The path to 1.0 is planned in [docs/roadmap-to-1.0.md](./docs/roadmap-to-1.0.md).
|
|
7
7
|
|
|
8
|
+
## [0.20.0] — 2026-06-12
|
|
9
|
+
|
|
10
|
+
The directive layer: the market map joined to your own CRM ground truth.
|
|
11
|
+
Two companies mapping the same category see the same fronts; their own
|
|
12
|
+
conversion fingerprints produce different directives.
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
|
|
16
|
+
- **`fullstackgtm market overlay`** — joins the observation store to a CRM
|
|
17
|
+
snapshot and a call corpus, and emits OCCUPY / PROMOTE / URGENT / RETREAT
|
|
18
|
+
directives. Deterministic throughout: claim mentions are word-boundary
|
|
19
|
+
matches of each claim's configured `terms` (and vendor `aliases`) against
|
|
20
|
+
call documents (`call parse` output, optionally deal-linked via a manifest);
|
|
21
|
+
every directive carries ≥1 observation id and ≥1 CRM statistic **with its
|
|
22
|
+
sample size**; explicit minimum-evidence thresholds (`--min-mentions`,
|
|
23
|
+
`--promote-lift`) refuse to mint strategy from small samples — and an
|
|
24
|
+
empty directive list is reported as an answer, not a failure.
|
|
25
|
+
- OCCUPY: open front the anchor doesn't own, buyers demonstrably discuss it.
|
|
26
|
+
- PROMOTE: anchor-quiet claim whose mentioned-deal win rate beats baseline.
|
|
27
|
+
- URGENT: a front the anchor is loud on drifted toward saturation
|
|
28
|
+
(`--prior-run`).
|
|
29
|
+
- RETREAT: saturated front, loud anchor, zero presence in won-deal calls.
|
|
30
|
+
- `--save --task-account <id>|--task-deal <id>` turns directives into
|
|
31
|
+
approval-gated `create_task` operations through the normal
|
|
32
|
+
plans → approve → apply gate. Nothing writes without approval.
|
|
33
|
+
- **`fullstackgtm market scale` + scale-sized report bubbles** — vendors may
|
|
34
|
+
carry `scaleSignals` (citable G2 review counts, LinkedIn headcount,
|
|
35
|
+
disclosed revenue, self-reported customers — each with sourceUrl, verbatim
|
|
36
|
+
quote, asOf, and caveat). A deterministic composite (log-normalized
|
|
37
|
+
per metric within the set, mean over available metrics, singleton metrics
|
|
38
|
+
skipped) yields a **relative scale index — never "market share"
|
|
39
|
+
unqualified** — and the strategic map's dot area becomes proportional to
|
|
40
|
+
it when every placeable vendor has signals (LOUD-count sizing otherwise;
|
|
41
|
+
the caption always states which, and bubbles are now area-proportional
|
|
42
|
+
either way).
|
|
43
|
+
- `MarketClaim.terms` and `MarketVendor.aliases` for deterministic mention
|
|
44
|
+
matching; `MarketVendor.scaleSignals`.
|
|
45
|
+
|
|
46
|
+
## [0.19.0] — 2026-06-11
|
|
47
|
+
|
|
48
|
+
Governed bulk writes, plus fixes from the 0.18 published-artifact verification.
|
|
49
|
+
|
|
50
|
+
### Added
|
|
51
|
+
|
|
52
|
+
- **`fullstackgtm bulk-update <account|contact|deal>`** — generic writes
|
|
53
|
+
through the same plan gate as everything else: `--where` filters (`=`,
|
|
54
|
+
`!=`, `~` substring, `:empty`/`:notempty`, `|` alternation) select records,
|
|
55
|
+
`--set`/`--archive`/`--create-task` define the change, and the result is a
|
|
56
|
+
dry-run patch plan needing explicit approval before `apply` — never a
|
|
57
|
+
direct write. `--require <field>=<value>` preconditions and
|
|
58
|
+
`--guard <object>:<where>:<none|some>` cross-record eligibility checks are
|
|
59
|
+
re-verified at apply time against the live CRM (mid-apply rechecks shrink
|
|
60
|
+
the audit→apply TOCTOU window); `--max-operations` caps blast radius.
|
|
61
|
+
- Eval tool card teaches agents to encode eligibility conditions in filters
|
|
62
|
+
rather than hand-selecting record ids.
|
|
63
|
+
|
|
64
|
+
### Fixed
|
|
65
|
+
|
|
66
|
+
- `--help` no longer executes: every `market` subcommand now short-circuits
|
|
67
|
+
to usage before config loads, credential checks, or side effects
|
|
68
|
+
(`market capture --help` used to *run the capture* — live fetches and
|
|
69
|
+
manifest writes; `market axes --help` ran the analysis). Top-level
|
|
70
|
+
commands without bespoke help (`audit`, `snapshot`, `suggest`, …) print
|
|
71
|
+
usage on `--help` instead of executing (`audit --help` used to silently
|
|
72
|
+
run the sample audit).
|
|
73
|
+
- `market report --format html` no longer crashes with a bare TypeError on
|
|
74
|
+
axes missing pole labels: `parseMarketConfig` now requires
|
|
75
|
+
`negativePole`/`positivePole` on every axis and says so.
|
|
76
|
+
- The 0.18.0 entry below documented the axis shape with a `poles` field that
|
|
77
|
+
never existed; the real fields are `negativePole`/`positivePole` (entry
|
|
78
|
+
corrected in place).
|
|
79
|
+
- `forcedToolCall` is now actually exported from the package root, as the
|
|
80
|
+
0.17.0 entry claimed.
|
|
81
|
+
- MCP `fullstackgtm_market_worksheet`/`_observe` with no
|
|
82
|
+
`market.config.json` in the server cwd return a "run `fullstackgtm market
|
|
83
|
+
init`" hint instead of a raw ENOENT.
|
|
84
|
+
|
|
8
85
|
## [0.18.0] — 2026-06-11
|
|
9
86
|
|
|
10
87
|
Axis discovery: earn a strategic 2×2 from the observations instead of
|
|
@@ -13,7 +90,7 @@ asserting one.
|
|
|
13
90
|
### Added
|
|
14
91
|
|
|
15
92
|
- **Axes as config** — `axes` in `market.config.json`: each axis is a
|
|
16
|
-
claim-scoring rubric (`{ id, label,
|
|
93
|
+
claim-scoring rubric (`{ id, label, negativePole, positivePole, rubric, status, claimScores }`,
|
|
17
94
|
null = axis doesn't apply to that claim); a vendor's position is the
|
|
18
95
|
intensity-weighted mean (loud=1, quiet=½) of the claims it voices.
|
|
19
96
|
`primaryAxes: [x, y]` picks the report's strategic map. Config validation
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { CanonicalGtmSnapshot, PatchPlan, PlanGuard } from "./types.ts";
|
|
2
|
+
export type BulkUpdateOptions = {
|
|
3
|
+
objectType: "account" | "contact" | "deal";
|
|
4
|
+
/** raw --where expressions, AND-ed together; at least one is required */
|
|
5
|
+
where: string[];
|
|
6
|
+
/** canonical field → new value; one action only */
|
|
7
|
+
set?: Record<string, string>;
|
|
8
|
+
/** propose archive_record instead of field writes */
|
|
9
|
+
archive?: boolean;
|
|
10
|
+
/** propose create_task on each matched record with this subject/body text */
|
|
11
|
+
createTask?: string;
|
|
12
|
+
/** explicit preconditions (field=value), re-verified at apply time */
|
|
13
|
+
require?: string[];
|
|
14
|
+
/**
|
|
15
|
+
* plan-level guards, raw form "<objectType>:<where>[;<where>…]:<none|some>",
|
|
16
|
+
* re-evaluated against a fresh snapshot at apply time; failure aborts the
|
|
17
|
+
* entire plan
|
|
18
|
+
*/
|
|
19
|
+
guard?: string[];
|
|
20
|
+
reason?: string;
|
|
21
|
+
/** refuse to build plans larger than this (default 500 operations) */
|
|
22
|
+
maxOperations?: number;
|
|
23
|
+
};
|
|
24
|
+
type WhereClause = {
|
|
25
|
+
field: string;
|
|
26
|
+
op: "eq" | "neq" | "contains" | "notcontains" | "empty" | "notempty";
|
|
27
|
+
value?: string;
|
|
28
|
+
raw: string;
|
|
29
|
+
};
|
|
30
|
+
export declare function parseWhere(expr: string): WhereClause;
|
|
31
|
+
export declare function parseGuard(raw: string): PlanGuard;
|
|
32
|
+
/** Ids of records matching a filter — used for apply-time filter re-verification. */
|
|
33
|
+
export declare function eligibleIds(snapshot: CanonicalGtmSnapshot, objectType: BulkUpdateOptions["objectType"], where: string[]): Set<string>;
|
|
34
|
+
/** Evaluate a plan guard against a snapshot. Returns null when satisfied, else a failure detail. */
|
|
35
|
+
export declare function evaluateGuard(snapshot: CanonicalGtmSnapshot, guard: PlanGuard): string | null;
|
|
36
|
+
export declare function buildBulkUpdatePlan(snapshot: CanonicalGtmSnapshot, options: BulkUpdateOptions): PatchPlan;
|
|
37
|
+
export {};
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Governed generic writes: `bulk-update` builds a dry-run patch plan from a
|
|
3
|
+
* filter over the canonical snapshot plus an action (field assignments, an
|
|
4
|
+
* archive directive, or a task to create). It NEVER writes — the plan flows
|
|
5
|
+
* through the same plans-approve → apply gate as audit plans.
|
|
6
|
+
*
|
|
7
|
+
* Safety model, in layers:
|
|
8
|
+
* - every set_field captures the record's live value as `beforeValue`
|
|
9
|
+
* (apply refuses the write if the written field drifted);
|
|
10
|
+
* - every equality filter on a real record field becomes an automatic
|
|
11
|
+
* PRECONDITION, re-verified at apply time (the plan's premise must still
|
|
12
|
+
* hold — guards against drift on fields other than the one written);
|
|
13
|
+
* - `--require field=value` adds explicit preconditions on top;
|
|
14
|
+
* - all operations for one record share a groupId, so multi-field updates
|
|
15
|
+
* are all-or-nothing per record.
|
|
16
|
+
*
|
|
17
|
+
* Filter grammar (each --where is AND-ed):
|
|
18
|
+
* field=value case-insensitive equality
|
|
19
|
+
* field!=value case-insensitive inequality
|
|
20
|
+
* field~value case-insensitive substring
|
|
21
|
+
* field:empty unset or empty string
|
|
22
|
+
* field:notempty set and non-empty
|
|
23
|
+
*
|
|
24
|
+
* Fields are canonical (ownerId, stage, closeDate, amount, domain, name,
|
|
25
|
+
* email, isClosed, accountId, …). Relational pseudo-fields are available in
|
|
26
|
+
* filters: deals and contacts get `account.name`, `account.domain`,
|
|
27
|
+
* `account.ownerId`, `account.contactCount`; accounts get `contactCount`
|
|
28
|
+
* and `openDealCount`.
|
|
29
|
+
*/
|
|
30
|
+
import { stableHash } from "./rules.js";
|
|
31
|
+
const FIELD_PATTERN = "[a-zA-Z][a-zA-Z0-9_]*(?:\\.[a-zA-Z][a-zA-Z0-9_]*)?";
|
|
32
|
+
export function parseWhere(expr) {
|
|
33
|
+
const empty = expr.match(new RegExp(`^(${FIELD_PATTERN}):(empty|notempty)$`));
|
|
34
|
+
if (empty)
|
|
35
|
+
return { field: empty[1], op: empty[2], raw: expr };
|
|
36
|
+
const neq = expr.match(new RegExp(`^(${FIELD_PATTERN})!=(.*)$`));
|
|
37
|
+
if (neq)
|
|
38
|
+
return { field: neq[1], op: "neq", value: neq[2], raw: expr };
|
|
39
|
+
const notContains = expr.match(new RegExp(`^(${FIELD_PATTERN})!~(.*)$`));
|
|
40
|
+
if (notContains)
|
|
41
|
+
return { field: notContains[1], op: "notcontains", value: notContains[2], raw: expr };
|
|
42
|
+
const contains = expr.match(new RegExp(`^(${FIELD_PATTERN})~(.*)$`));
|
|
43
|
+
if (contains)
|
|
44
|
+
return { field: contains[1], op: "contains", value: contains[2], raw: expr };
|
|
45
|
+
const eq = expr.match(new RegExp(`^(${FIELD_PATTERN})=(.*)$`));
|
|
46
|
+
if (eq)
|
|
47
|
+
return { field: eq[1], op: "eq", value: eq[2], raw: expr };
|
|
48
|
+
throw new Error(`Cannot parse --where "${expr}". Use field=value, field!=value, field~substring, field!~substring, field:empty, or field:notempty.`);
|
|
49
|
+
}
|
|
50
|
+
function fieldValue(view, field) {
|
|
51
|
+
const value = view[field];
|
|
52
|
+
if (value === undefined || value === null)
|
|
53
|
+
return "";
|
|
54
|
+
return String(value);
|
|
55
|
+
}
|
|
56
|
+
function matches(view, clause) {
|
|
57
|
+
const actual = fieldValue(view, clause.field).toLowerCase();
|
|
58
|
+
// `|` alternation: eq/contains match ANY alternative; neq/notcontains
|
|
59
|
+
// must hold against ALL alternatives.
|
|
60
|
+
const alternatives = (clause.value ?? "").toLowerCase().split("|");
|
|
61
|
+
switch (clause.op) {
|
|
62
|
+
case "eq":
|
|
63
|
+
return alternatives.some((a) => actual === a);
|
|
64
|
+
case "neq":
|
|
65
|
+
return alternatives.every((a) => actual !== a);
|
|
66
|
+
case "contains":
|
|
67
|
+
return alternatives.some((a) => actual.includes(a));
|
|
68
|
+
case "notcontains":
|
|
69
|
+
return alternatives.every((a) => !actual.includes(a));
|
|
70
|
+
case "empty":
|
|
71
|
+
return actual === "";
|
|
72
|
+
case "notempty":
|
|
73
|
+
return actual !== "";
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
const COLLECTIONS = {
|
|
77
|
+
account: "accounts",
|
|
78
|
+
contact: "contacts",
|
|
79
|
+
deal: "deals",
|
|
80
|
+
};
|
|
81
|
+
const RELATIONAL_FIELDS = ["account.name", "account.domain", "account.ownerId", "account.contactCount", "account.openDealStages"];
|
|
82
|
+
/**
|
|
83
|
+
* Filterable fields per object type. Filters/requires/guards referencing any
|
|
84
|
+
* other field are rejected at plan time: a typo'd field silently evaluating
|
|
85
|
+
* to empty would make a ":none" guard pass vacuously — a safety assertion
|
|
86
|
+
* that never fires. Strictness turns typos into immediate, correctable
|
|
87
|
+
* errors.
|
|
88
|
+
*/
|
|
89
|
+
const VALID_FIELDS = {
|
|
90
|
+
account: new Set(["id", "crmId", "name", "domain", "industry", "ownerId", "employeeCount", "annualRevenue", "lastActivityAt", "lastSyncAt", "contactCount", "openDealCount", "openDealStages"]),
|
|
91
|
+
contact: new Set(["id", "crmId", "accountId", "firstName", "lastName", "email", "phone", "title", "ownerId", "lastSyncAt", ...RELATIONAL_FIELDS]),
|
|
92
|
+
deal: new Set(["id", "crmId", "accountId", "ownerId", "name", "amount", "currency", "stage", "closeDate", "dealType", "forecastCategory", "nextStep", "probability", "isClosed", "isWon", "lastActivityAt", "lastSyncAt", ...RELATIONAL_FIELDS]),
|
|
93
|
+
};
|
|
94
|
+
function assertValidFields(objectType, clauses, context) {
|
|
95
|
+
for (const clause of clauses) {
|
|
96
|
+
if (!VALID_FIELDS[objectType].has(clause.field)) {
|
|
97
|
+
throw new Error(`Unknown field "${clause.field}" in ${context} "${clause.raw}" for ${objectType}s. Valid fields: ${[...VALID_FIELDS[objectType]].join(", ")}.`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Fields that are derived in the canonical model (no provider property to
|
|
103
|
+
* re-read at apply time) or relational — excluded from auto-preconditions.
|
|
104
|
+
*/
|
|
105
|
+
const NON_READABLE_FIELDS = new Set([
|
|
106
|
+
// derived in the canonical model — no provider property to re-read
|
|
107
|
+
"isClosed", "isWon", "forecastCategory", "probability", "lastActivityAt", "lastSyncAt",
|
|
108
|
+
// identity/bookkeeping fields — preconditions on these are meaningless
|
|
109
|
+
"id", "crmId", "provider", "identities",
|
|
110
|
+
]);
|
|
111
|
+
/** Build the filter-evaluation view: record fields + relational pseudo-fields. */
|
|
112
|
+
function buildViews(snapshot, objectType) {
|
|
113
|
+
const accountsById = new Map(snapshot.accounts.map((a) => [a.id, a]));
|
|
114
|
+
const contactCountByAccount = new Map();
|
|
115
|
+
for (const c of snapshot.contacts) {
|
|
116
|
+
if (c.accountId)
|
|
117
|
+
contactCountByAccount.set(c.accountId, (contactCountByAccount.get(c.accountId) ?? 0) + 1);
|
|
118
|
+
}
|
|
119
|
+
const openDealCountByAccount = new Map();
|
|
120
|
+
const openDealStagesByAccount = new Map();
|
|
121
|
+
for (const d of snapshot.deals) {
|
|
122
|
+
if (d.accountId && !d.isClosed) {
|
|
123
|
+
openDealCountByAccount.set(d.accountId, (openDealCountByAccount.get(d.accountId) ?? 0) + 1);
|
|
124
|
+
const stages = openDealStagesByAccount.get(d.accountId) ?? [];
|
|
125
|
+
if (d.stage)
|
|
126
|
+
stages.push(d.stage);
|
|
127
|
+
openDealStagesByAccount.set(d.accountId, stages);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
const records = snapshot[COLLECTIONS[objectType]];
|
|
131
|
+
return records.map((record) => {
|
|
132
|
+
const view = { ...record };
|
|
133
|
+
if (objectType === "account") {
|
|
134
|
+
view.contactCount = contactCountByAccount.get(String(record.id)) ?? 0;
|
|
135
|
+
view.openDealCount = openDealCountByAccount.get(String(record.id)) ?? 0;
|
|
136
|
+
view.openDealStages = (openDealStagesByAccount.get(String(record.id)) ?? []).join(",");
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
const account = record.accountId ? accountsById.get(String(record.accountId)) : undefined;
|
|
140
|
+
view["account.name"] = account?.name ?? "";
|
|
141
|
+
view["account.domain"] = account?.domain ?? "";
|
|
142
|
+
view["account.ownerId"] = account?.ownerId ?? "";
|
|
143
|
+
view["account.contactCount"] = account ? (contactCountByAccount.get(account.id) ?? 0) : 0;
|
|
144
|
+
view["account.openDealStages"] = account ? (openDealStagesByAccount.get(account.id) ?? []).join(",") : "";
|
|
145
|
+
}
|
|
146
|
+
return { record, view };
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
export function parseGuard(raw) {
|
|
150
|
+
const first = raw.indexOf(":");
|
|
151
|
+
const last = raw.lastIndexOf(":");
|
|
152
|
+
if (first === -1 || last === first) {
|
|
153
|
+
throw new Error(`Cannot parse --guard "${raw}". Use <account|contact|deal>:<where>[;<where>…]:<none|some>.`);
|
|
154
|
+
}
|
|
155
|
+
const objectType = raw.slice(0, first);
|
|
156
|
+
const expect = raw.slice(last + 1);
|
|
157
|
+
const where = raw.slice(first + 1, last).split(";").map((s) => s.trim()).filter(Boolean);
|
|
158
|
+
if (!["account", "contact", "deal"].includes(objectType) || !["none", "some"].includes(expect) || where.length === 0) {
|
|
159
|
+
throw new Error(`Cannot parse --guard "${raw}". Use <account|contact|deal>:<where>[;<where>…]:<none|some>.`);
|
|
160
|
+
}
|
|
161
|
+
// validate eagerly at plan time: a typo'd guard must fail loudly, never
|
|
162
|
+
// pass vacuously at apply time
|
|
163
|
+
assertValidFields(objectType, where.map(parseWhere), "--guard");
|
|
164
|
+
return { objectType: objectType, where, expect: expect, description: raw };
|
|
165
|
+
}
|
|
166
|
+
/** Ids of records matching a filter — used for apply-time filter re-verification. */
|
|
167
|
+
export function eligibleIds(snapshot, objectType, where) {
|
|
168
|
+
const clauses = where.map(parseWhere);
|
|
169
|
+
const views = buildViews(snapshot, objectType);
|
|
170
|
+
return new Set(views.filter(({ view }) => clauses.every((c) => matches(view, c))).map(({ record }) => String(record.id)));
|
|
171
|
+
}
|
|
172
|
+
/** Evaluate a plan guard against a snapshot. Returns null when satisfied, else a failure detail. */
|
|
173
|
+
export function evaluateGuard(snapshot, guard) {
|
|
174
|
+
const clauses = guard.where.map(parseWhere);
|
|
175
|
+
const views = buildViews(snapshot, guard.objectType);
|
|
176
|
+
const matchCount = views.filter(({ view }) => clauses.every((c) => matches(view, c))).length;
|
|
177
|
+
const ok = guard.expect === "none" ? matchCount === 0 : matchCount > 0;
|
|
178
|
+
if (ok)
|
|
179
|
+
return null;
|
|
180
|
+
return `Guard failed: expected ${guard.expect === "none" ? "no" : "at least one"} ${guard.objectType}(s) matching [${guard.where.join(" AND ")}], found ${matchCount}.`;
|
|
181
|
+
}
|
|
182
|
+
export function buildBulkUpdatePlan(snapshot, options) {
|
|
183
|
+
const maxOperations = options.maxOperations ?? 500;
|
|
184
|
+
if (options.where.length === 0) {
|
|
185
|
+
throw new Error("bulk-update requires at least one --where filter — refusing to build an unscoped mass write.");
|
|
186
|
+
}
|
|
187
|
+
const hasSet = options.set && Object.keys(options.set).length > 0;
|
|
188
|
+
const actions = [hasSet, options.archive, options.createTask !== undefined].filter(Boolean).length;
|
|
189
|
+
if (actions !== 1) {
|
|
190
|
+
throw new Error("bulk-update needs exactly one action: --set <field>=<value> (repeatable), --archive, or --create-task <text>.");
|
|
191
|
+
}
|
|
192
|
+
const clauses = options.where.map(parseWhere);
|
|
193
|
+
assertValidFields(options.objectType, clauses, "--where");
|
|
194
|
+
const WRITABLE_BLOCKLIST = new Set(["id", "crmId", "contactCount", "openDealCount", "openDealStages"]);
|
|
195
|
+
for (const field of Object.keys(options.set ?? {})) {
|
|
196
|
+
if (!VALID_FIELDS[options.objectType].has(field) || WRITABLE_BLOCKLIST.has(field) || field.includes(".")) {
|
|
197
|
+
throw new Error(`Cannot --set "${field}" on ${options.objectType}s — not a writable canonical field.`);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
const views = buildViews(snapshot, options.objectType);
|
|
201
|
+
const matched = views.filter(({ view }) => clauses.every((c) => matches(view, c)));
|
|
202
|
+
if (matched.length > maxOperations) {
|
|
203
|
+
throw new Error(`Filter matched ${matched.length} ${COLLECTIONS[options.objectType]} — above the ${maxOperations}-record safety cap. Narrow the --where filter or raise --max-operations explicitly.`);
|
|
204
|
+
}
|
|
205
|
+
// Preconditions: explicit --require, plus every equality filter on a real
|
|
206
|
+
// (re-readable, non-relational) field. The premise the plan was built on
|
|
207
|
+
// is re-verified per record at apply time.
|
|
208
|
+
const writtenFields = new Set(Object.keys(options.set ?? {}));
|
|
209
|
+
const preconditionSpecs = [];
|
|
210
|
+
for (const raw of options.require ?? []) {
|
|
211
|
+
const clause = parseWhere(raw);
|
|
212
|
+
if (clause.op !== "eq" || clause.field.includes(".") || (clause.value ?? "").includes("|")) {
|
|
213
|
+
throw new Error(`--require must be a direct single-value field equality (field=value), got "${raw}".`);
|
|
214
|
+
}
|
|
215
|
+
assertValidFields(options.objectType, [clause], "--require");
|
|
216
|
+
preconditionSpecs.push({ field: clause.field, expectedValue: clause.value ?? "" });
|
|
217
|
+
}
|
|
218
|
+
for (const clause of clauses) {
|
|
219
|
+
if (clause.op !== "eq")
|
|
220
|
+
continue;
|
|
221
|
+
if ((clause.value ?? "").includes("|"))
|
|
222
|
+
continue; // alternations are not single-value preconditions
|
|
223
|
+
if (clause.field.includes(".") || NON_READABLE_FIELDS.has(clause.field))
|
|
224
|
+
continue;
|
|
225
|
+
if (writtenFields.has(clause.field))
|
|
226
|
+
continue; // beforeValue already guards it
|
|
227
|
+
if (preconditionSpecs.some((p) => p.field === clause.field))
|
|
228
|
+
continue;
|
|
229
|
+
preconditionSpecs.push({ field: clause.field, expectedValue: clause.value ?? "" });
|
|
230
|
+
}
|
|
231
|
+
const whereText = options.where.join(" AND ");
|
|
232
|
+
const action = options.archive
|
|
233
|
+
? `archive`
|
|
234
|
+
: options.createTask !== undefined
|
|
235
|
+
? `create task "${options.createTask}"`
|
|
236
|
+
: `set ${Object.entries(options.set).map(([k, v]) => `${k}=${v}`).join(", ")}`;
|
|
237
|
+
const reason = options.reason ?? `bulk-update: ${action} where ${whereText}`;
|
|
238
|
+
const operations = [];
|
|
239
|
+
for (const { record } of matched) {
|
|
240
|
+
const objectId = String(record.id);
|
|
241
|
+
const groupId = `grp_${options.objectType}_${objectId}`;
|
|
242
|
+
const preconditions = preconditionSpecs.map((p) => ({
|
|
243
|
+
field: p.field,
|
|
244
|
+
// expected value is the record's CURRENT canonical value, not the
|
|
245
|
+
// filter literal — preserves casing/format the provider will echo back
|
|
246
|
+
expectedValue: record[p.field] ?? p.expectedValue,
|
|
247
|
+
}));
|
|
248
|
+
const shared = {
|
|
249
|
+
objectType: options.objectType,
|
|
250
|
+
objectId,
|
|
251
|
+
reason,
|
|
252
|
+
approvalRequired: true,
|
|
253
|
+
sourceRuleOrPolicy: "bulk-update",
|
|
254
|
+
...(preconditions.length > 0 ? { preconditions } : {}),
|
|
255
|
+
groupId,
|
|
256
|
+
};
|
|
257
|
+
if (options.archive) {
|
|
258
|
+
operations.push({
|
|
259
|
+
...shared,
|
|
260
|
+
id: `op_${stableHash(`bulk-archive:${options.objectType}:${objectId}:${whereText}`)}`,
|
|
261
|
+
operation: "archive_record",
|
|
262
|
+
beforeValue: null,
|
|
263
|
+
afterValue: null,
|
|
264
|
+
riskLevel: "high",
|
|
265
|
+
rollback: "Archived records can be restored from the provider's recycle bin within its retention window.",
|
|
266
|
+
});
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
if (options.createTask !== undefined) {
|
|
270
|
+
operations.push({
|
|
271
|
+
...shared,
|
|
272
|
+
id: `op_${stableHash(`bulk-task:${options.objectType}:${objectId}:${options.createTask}`)}`,
|
|
273
|
+
operation: "create_task",
|
|
274
|
+
field: "follow_up_task",
|
|
275
|
+
beforeValue: null,
|
|
276
|
+
afterValue: options.createTask,
|
|
277
|
+
riskLevel: "low",
|
|
278
|
+
rollback: "Delete the created task.",
|
|
279
|
+
});
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
for (const [field, value] of Object.entries(options.set)) {
|
|
283
|
+
operations.push({
|
|
284
|
+
...shared,
|
|
285
|
+
id: `op_${stableHash(`bulk-set:${options.objectType}:${objectId}:${field}:${value}`)}`,
|
|
286
|
+
operation: "set_field",
|
|
287
|
+
field,
|
|
288
|
+
beforeValue: record[field] ?? null,
|
|
289
|
+
afterValue: value,
|
|
290
|
+
riskLevel: "medium",
|
|
291
|
+
rollback: `Set ${field} back to ${JSON.stringify(record[field] ?? null)}.`,
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
const guards = (options.guard ?? []).map(parseGuard);
|
|
296
|
+
// guards must hold at plan time too — building a plan whose guard already
|
|
297
|
+
// fails is a footgun, surface it immediately
|
|
298
|
+
for (const guard of guards) {
|
|
299
|
+
const failure = evaluateGuard(snapshot, guard);
|
|
300
|
+
if (failure)
|
|
301
|
+
throw new Error(`${failure} The guard already fails against the current snapshot — the plan would never apply.`);
|
|
302
|
+
}
|
|
303
|
+
return {
|
|
304
|
+
id: `patch_plan_${stableHash(`bulk:${snapshot.provider}:${snapshot.generatedAt}:${whereText}:${action}:${operations.length}`)}`,
|
|
305
|
+
title: `Bulk update: ${options.objectType}s where ${whereText}`,
|
|
306
|
+
createdAt: snapshot.generatedAt,
|
|
307
|
+
status: operations.length > 0 ? "needs_approval" : "draft",
|
|
308
|
+
dryRun: true,
|
|
309
|
+
summary: `${matched.length} ${COLLECTIONS[options.objectType]} matched (${whereText}); ${operations.length} proposed dry-run operations (${action}).${guards.length > 0 ? ` ${guards.length} apply-time guard(s).` : ""}`,
|
|
310
|
+
findings: [],
|
|
311
|
+
operations,
|
|
312
|
+
filter: { objectType: options.objectType, where: options.where },
|
|
313
|
+
...(guards.length > 0 ? { guards } : {}),
|
|
314
|
+
};
|
|
315
|
+
}
|