@tenonhq/dovetail-servicenow 0.0.34 → 0.0.35

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/README.md CHANGED
@@ -93,6 +93,13 @@ npx dove-sn add-choices \
93
93
 
94
94
  # JSON payload form (recommended for >5 choices)
95
95
  npx dove-sn add-choices --from-json ./choices.json
96
+
97
+ # Retire values — a SOFT delete (sets inactive=true), never a row drop
98
+ npx dove-sn remove-choices \
99
+ --table x_cadso_core_event \
100
+ --column state \
101
+ --update-set 0083c3bb33d003507b18bc534d5c7b6d \
102
+ --values "expired,failed"
96
103
  ```
97
104
 
98
105
  JSON payload shape:
@@ -110,10 +117,37 @@ JSON payload shape:
110
117
  }
111
118
  ```
112
119
 
120
+ `remove-choices` takes the same `--from-json` form, with a `values` array in
121
+ place of `choices`.
122
+
123
+ ### Removal semantics
124
+
125
+ - **Soft delete.** Sets `sys_choice.inactive = true`; the row is retained and
126
+ the operation is reversible by re-adding the value. `sys_dictionary.choice` is
127
+ left alone — retiring values does not un-make the column a choice field.
128
+ - **Idempotent.** `deactivated` (a live row was flipped) / `unchanged` (already
129
+ inactive) / `missing` (no such value on the field). Re-running writes nothing.
130
+ - **Duplicates.** `sys_choice` has no uniqueness constraint on
131
+ `(name, element, value, language)`, so a field can hold several live rows for
132
+ one value. Every live row is deactivated, and `sysIds` lists all of them — a
133
+ length above 1 is your signal the field needs cleaning up.
134
+ - **Inherited choices are not covered.** Matching is scoped to
135
+ `sys_choice.name = <table>`, so a value defined on a *parent* table reports
136
+ `missing` rather than being deactivated. Hiding one on a child table needs an
137
+ override row this verb does not write.
138
+ - **Promotion.** ServiceNow captures the change as a single `Choice list` record
139
+ for the whole column, not one per value — so promoting the update set moves
140
+ the entire choice-list state for that column.
141
+
113
142
  ## Programmatic
114
143
 
115
144
  ```ts
116
- import { createClient, addChoicesToField } from "@tenonhq/dovetail-servicenow";
145
+ import {
146
+ createClient,
147
+ addChoicesToField,
148
+ removeChoicesFromField,
149
+ ChoiceWriteError,
150
+ } from "@tenonhq/dovetail-servicenow";
117
151
 
118
152
  var client = createClient({});
119
153
  var result = await addChoicesToField(client, { /* ... */ });
@@ -123,6 +157,32 @@ console.log(result.choices);
123
157
  // { value: "delivered", label: "Delivered", sysId: "...", action: "created" },
124
158
  // { value: "failed", label: "Failed", sysId: "...", action: "created" }
125
159
  // ]
160
+
161
+ var removed = await removeChoicesFromField(client, {
162
+ table: "x_cadso_core_event",
163
+ column: "state",
164
+ updateSetSysId: "0083c3bb33d003507b18bc534d5c7b6d",
165
+ values: ["expired"],
166
+ });
167
+ // removed.choices[0] -> { value: "expired", sysId: "...", sysIds: ["..."], action: "deactivated" }
168
+ ```
169
+
170
+ Both verbs write one value at a time. If a write fails partway through they
171
+ throw a **`ChoiceWriteError`** carrying `completed` (the values that already
172
+ landed, in result shape), `failedValue` (the one that threw — its state on the
173
+ instance is unknown), and `cause` (the original error). Catch it rather than
174
+ re-querying the instance to work out how far the run got:
175
+
176
+ ```ts
177
+ try {
178
+ await removeChoicesFromField(client, params);
179
+ } catch (e) {
180
+ if (e instanceof ChoiceWriteError) {
181
+ console.error("already deactivated:", e.completed.map((r) => r.value));
182
+ console.error("verify by hand:", e.failedValue);
183
+ }
184
+ throw e;
185
+ }
126
186
  ```
127
187
 
128
188
  ## Form, list & view layouts
package/dist/choices.d.ts CHANGED
@@ -8,7 +8,29 @@
8
8
  * so choices stay in the same application as the field.
9
9
  */
10
10
  import type { ServiceNowClient } from "./client";
11
- import type { AddChoicesParams, AddChoicesResult } from "./types";
11
+ import type { AddChoicesParams, AddChoicesResult, ChoiceActionResult, ChoiceRemovalResult, RemoveChoicesParams, RemoveChoicesResult } from "./types";
12
+ /**
13
+ * Thrown when a choices write fails partway through the value loop.
14
+ *
15
+ * Both verbs write one value at a time. Without this, a throw on value 3 of 5
16
+ * unwound the whole call and took the local `results` array with it — the
17
+ * caller could not tell which values had already been deactivated (or which
18
+ * sys_choice rows had already been created on the add path) without going and
19
+ * querying the instance. That is the worst possible moment to lose the record:
20
+ * the writes already landed and are already captured in the update set.
21
+ *
22
+ * `completed` holds the values that finished before the failure, in order, in
23
+ * exactly the shape they would have had in a successful result. `failedValue`
24
+ * is the value being processed when the write threw, and is NOT in `completed`
25
+ * — its state on the instance is unknown (the row may or may not have been
26
+ * written before the error), so it needs checking by hand.
27
+ */
28
+ export declare class ChoiceWriteError extends Error {
29
+ readonly completed: Array<ChoiceActionResult | ChoiceRemovalResult>;
30
+ readonly failedValue: string;
31
+ readonly cause: unknown;
32
+ constructor(verb: string, failedValue: string, completed: Array<ChoiceActionResult | ChoiceRemovalResult>, cause: unknown);
33
+ }
12
34
  export declare function encodeQueryValue(v: string): string;
13
35
  /**
14
36
  * Upsert choices for a field and (optionally) toggle sys_dictionary.choice.
@@ -16,3 +38,33 @@ export declare function encodeQueryValue(v: string): string;
16
38
  * for every row and skips the dictionary write when no change is required.
17
39
  */
18
40
  export declare function addChoicesToField(client: ServiceNowClient, params: AddChoicesParams): Promise<AddChoicesResult>;
41
+ /**
42
+ * Soft-delete choice values for a field: set `inactive=true` on each matching
43
+ * sys_choice row via pushWithUpdateSet. NEVER a hard delete — the row stays, so the
44
+ * change is reversible and the historical value still resolves on records that
45
+ * already hold it. (A hard drop of sys_choice is deliberately deferred; see DEV-511.)
46
+ *
47
+ * Idempotent:
48
+ * - active value -> "deactivated" (every live row for it flipped to inactive —
49
+ * usually one write, but more when the field holds duplicates)
50
+ * - already inactive -> "unchanged" (no write)
51
+ * - value not found -> "missing" (no write)
52
+ *
53
+ * The dictionary row is fetched only to PROVE the field exists — without that guard a
54
+ * mistyped column reports every value as "missing", the silent-failure this family
55
+ * exists to catch. sys_dictionary.choice is left alone on purpose: removing values
56
+ * does not un-make the column a choice field.
57
+ *
58
+ * Repeated values are collapsed before the loop, so `["a", "a"]` costs no more than
59
+ * `["a"]` and yields one result row — an idempotent verb must not depend on the caller
60
+ * de-duplicating first. (That is "no EXTRA writes from repeats", not "exactly one
61
+ * write": a single value still takes one write per live duplicate row on the field.)
62
+ *
63
+ * LIMITATION — inherited choices. Matching is scoped to `sys_choice.name = <table>`, so
64
+ * a value defined on a PARENT table (task.state inherited by a child) reports "missing"
65
+ * rather than being deactivated. Hiding an inherited choice on a child table needs an
66
+ * override row on the child, which this verb does not write. For the x_cadso_* tables
67
+ * this family targets that case does not arise; on extended OOB tables, treat a
68
+ * surprising "missing" as a signal to check the parent.
69
+ */
70
+ export declare function removeChoicesFromField(client: ServiceNowClient, params: RemoveChoicesParams): Promise<RemoveChoicesResult>;
package/dist/choices.js CHANGED
@@ -9,8 +9,59 @@
9
9
  * so choices stay in the same application as the field.
10
10
  */
11
11
  Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.ChoiceWriteError = void 0;
12
13
  exports.encodeQueryValue = encodeQueryValue;
13
14
  exports.addChoicesToField = addChoicesToField;
15
+ exports.removeChoicesFromField = removeChoicesFromField;
16
+ /**
17
+ * Thrown when a choices write fails partway through the value loop.
18
+ *
19
+ * Both verbs write one value at a time. Without this, a throw on value 3 of 5
20
+ * unwound the whole call and took the local `results` array with it — the
21
+ * caller could not tell which values had already been deactivated (or which
22
+ * sys_choice rows had already been created on the add path) without going and
23
+ * querying the instance. That is the worst possible moment to lose the record:
24
+ * the writes already landed and are already captured in the update set.
25
+ *
26
+ * `completed` holds the values that finished before the failure, in order, in
27
+ * exactly the shape they would have had in a successful result. `failedValue`
28
+ * is the value being processed when the write threw, and is NOT in `completed`
29
+ * — its state on the instance is unknown (the row may or may not have been
30
+ * written before the error), so it needs checking by hand.
31
+ */
32
+ class ChoiceWriteError extends Error {
33
+ completed;
34
+ failedValue;
35
+ cause;
36
+ constructor(verb, failedValue, completed, cause) {
37
+ var detail = cause instanceof Error
38
+ ? cause.message
39
+ : String(cause == null ? "" : cause);
40
+ super(verb +
41
+ ' failed on value "' +
42
+ failedValue +
43
+ '" after completing ' +
44
+ completed.length +
45
+ " value(s): " +
46
+ detail +
47
+ "\nAlready written (captured in the update set): " +
48
+ (completed.length === 0
49
+ ? "none"
50
+ : completed
51
+ .map(function (r) {
52
+ return r.value + " [" + r.action + "]";
53
+ })
54
+ .join(", ")) +
55
+ '\nState of "' +
56
+ failedValue +
57
+ '" on the instance is unknown — verify it directly.');
58
+ this.name = "ChoiceWriteError";
59
+ this.completed = completed;
60
+ this.failedValue = failedValue;
61
+ this.cause = cause;
62
+ }
63
+ }
64
+ exports.ChoiceWriteError = ChoiceWriteError;
14
65
  function encodeQueryValue(v) {
15
66
  // ServiceNow encoded-query values: commas/carets/equals are special. We
16
67
  // don't expect them in table/column/value/language inputs, but keep this
@@ -75,8 +126,110 @@ async function fetchUpdateSet(client, sysId) {
75
126
  }
76
127
  return row;
77
128
  }
78
- async function fetchExistingChoices(client, table, column) {
79
- return client.table.query("sys_choice", "name=" + encodeQueryValue(table) + "^element=" + encodeQueryValue(column), 1000);
129
+ var CHOICE_PAGE_LIMIT = 1000;
130
+ /**
131
+ * Values per encoded-query batch. Keeps `valueIN a,b,c…` comfortably inside practical
132
+ * URL limits when a caller passes a long list.
133
+ */
134
+ var CHOICE_QUERY_BATCH = 50;
135
+ /**
136
+ * Read the existing sys_choice rows for JUST the values being acted on.
137
+ *
138
+ * Scoped rather than whole-field on purpose. Reading every row of the field means the
139
+ * result set grows with the field, not with the request, and `client.table.query` has no
140
+ * `sysparm_offset` — so a field with more choices than one page could only be handled by
141
+ * guessing at a truncated read (silently wrong: the add path inserts a duplicate, the
142
+ * remove path reports a live value as "missing") or by refusing outright, which would
143
+ * make both verbs unusable on large choice sets. Asking only for the requested values
144
+ * bounds the read by the request, so large fields stay editable and there is nothing to
145
+ * truncate.
146
+ *
147
+ * Note that values are interpolated into an encoded query, so `encodeQueryValue` rejects
148
+ * ones carrying `,` `^` `=`. Both CLI surfaces already split on those characters, so such
149
+ * values were never expressible there; the library paths now refuse them loudly rather
150
+ * than building a malformed query.
151
+ */
152
+ async function fetchExistingChoices(client, table, column, values) {
153
+ var base = "name=" + encodeQueryValue(table) + "^element=" + encodeQueryValue(column);
154
+ var out = [];
155
+ for (var i = 0; i < values.length; i += CHOICE_QUERY_BATCH) {
156
+ var batch = values.slice(i, i + CHOICE_QUERY_BATCH).map(encodeQueryValue);
157
+ // Ask for one MORE than the ceiling: requesting exactly it cannot distinguish "there
158
+ // are exactly that many" from "there are more and this is page 1".
159
+ var rows = await client.table.query("sys_choice", base + "^valueIN" + batch.join(","), CHOICE_PAGE_LIMIT + 1);
160
+ // A backstop, not an everyday path: this batch asked for at most CHOICE_QUERY_BATCH
161
+ // values, so overflowing a page means one value has an absurd number of duplicate
162
+ // rows. Refuse rather than act on a read that may be incomplete.
163
+ if (rows.length > CHOICE_PAGE_LIMIT) {
164
+ throw new Error("Refusing to act on a truncated choice list: " +
165
+ table +
166
+ "." +
167
+ column +
168
+ " returned more than " +
169
+ CHOICE_PAGE_LIMIT +
170
+ " sys_choice rows for a single batch of requested values, so the read cannot " +
171
+ "be trusted to be complete.");
172
+ }
173
+ out = out.concat(rows);
174
+ }
175
+ return out;
176
+ }
177
+ /**
178
+ * Group the instance's rows by language::value, keeping EVERY row per key.
179
+ *
180
+ * sys_choice has no uniqueness constraint on (name, element, value, language), so a
181
+ * field can genuinely hold two live rows for one value. Indexing to a single row would
182
+ * act on one and silently leave the other — the tool reports success while the choice
183
+ * still renders in the dropdown.
184
+ */
185
+ function groupExistingByKey(rows) {
186
+ // Null-prototype: keys derive from record data, and Object.prototype members must not
187
+ // masquerade as existing entries. See dedupe() for the failure this avoids.
188
+ var byKey = Object.create(null);
189
+ rows.forEach(function (row) {
190
+ var key = (row.language || "en") + "::" + row.value;
191
+ if (!byKey[key])
192
+ byKey[key] = [];
193
+ byKey[key].push(row);
194
+ });
195
+ return byKey;
196
+ }
197
+ /**
198
+ * Collapse choices that target the same language::value. Position is first-seen, but
199
+ * the LAST spec wins, so `[{a,"Old"},{a,"New"}]` upserts the label the caller asked for
200
+ * most recently rather than silently writing both.
201
+ */
202
+ function dedupeChoices(choices) {
203
+ var byKey = Object.create(null);
204
+ var order = [];
205
+ choices.forEach(function (c) {
206
+ var key = (c.language || "en") + "::" + c.value;
207
+ if (!byKey[key])
208
+ order.push(key);
209
+ byKey[key] = c;
210
+ });
211
+ return order.map(function (key) {
212
+ return byKey[key];
213
+ });
214
+ }
215
+ /**
216
+ * Preserve first-seen order while dropping repeats.
217
+ *
218
+ * The lookup is a null-prototype map because the keys are raw choice values: on a plain
219
+ * `{}`, `seen["constructor"]` / `["toString"]` / `["__proto__"]` are already truthy via
220
+ * Object.prototype, so a value with one of those names would be treated as a repeat and
221
+ * silently dropped from the request — never written, never even reported.
222
+ */
223
+ function dedupe(values) {
224
+ var seen = Object.create(null);
225
+ var out = [];
226
+ values.forEach(function (v) {
227
+ if (seen[v])
228
+ return;
229
+ seen[v] = true;
230
+ out.push(v);
231
+ });
232
+ return out;
80
233
  }
81
234
  function buildChoiceFields(table, column, choice, scope) {
82
235
  var fields = {
@@ -134,27 +287,40 @@ async function addChoicesToField(client, params) {
134
287
  });
135
288
  choiceNow = targetChoiceType;
136
289
  }
137
- var existing = await fetchExistingChoices(client, params.table, params.column);
138
- var existingByValue = {};
139
- existing.forEach(function (row) {
140
- var key = (row.language || "en") + "::" + row.value;
141
- existingByValue[key] = row;
142
- });
290
+ // Collapse repeats on language::value, last spec wins. Without this a value listed
291
+ // twice is created twice — the cache is read once up front, so the second pass still
292
+ // sees "not present" and inserts a genuine duplicate sys_choice row.
293
+ var choices = dedupeChoices(params.choices);
294
+ // Read only the values being upserted — see fetchExistingChoices on why the read is
295
+ // scoped to the request rather than the whole field.
296
+ var existing = await fetchExistingChoices(client, params.table, params.column, dedupe(choices.map(function (c) {
297
+ return c.value;
298
+ })));
299
+ var existingByValue = groupExistingByKey(existing);
143
300
  var results = [];
144
- for (var i = 0; i < params.choices.length; i += 1) {
145
- var choice = params.choices[i];
301
+ for (var i = 0; i < choices.length; i += 1) {
302
+ var choice = choices[i];
146
303
  var key = (choice.language || "en") + "::" + choice.value;
147
- var match = existingByValue[key];
148
- if (match && isUnchanged(match, choice)) {
304
+ var matches = existingByValue[key] || [];
305
+ var matchSysIds = matches.map(function (row) {
306
+ return row.sys_id;
307
+ });
308
+ // Every live row for this value, not just one: a field can already hold duplicates,
309
+ // and updating one would leave the other showing the old label in the dropdown.
310
+ var stale = matches.filter(function (row) {
311
+ return !isUnchanged(row, choice);
312
+ });
313
+ if (matches.length > 0 && stale.length === 0) {
149
314
  results.push({
150
315
  value: choice.value,
151
316
  label: choice.label,
152
- sysId: match.sys_id,
317
+ sysId: matchSysIds[0],
318
+ sysIds: matchSysIds,
153
319
  action: "unchanged",
154
320
  });
155
321
  continue;
156
322
  }
157
- if (match) {
323
+ if (matches.length > 0) {
158
324
  var updFields = {
159
325
  label: choice.label,
160
326
  language: choice.language || "en",
@@ -163,30 +329,45 @@ async function addChoicesToField(client, params) {
163
329
  if (choice.sequence != null) {
164
330
  updFields.sequence = String(choice.sequence);
165
331
  }
166
- await client.claude.pushWithUpdateSet({
167
- update_set_sys_id: params.updateSetSysId,
168
- table: "sys_choice",
169
- record_sys_id: match.sys_id,
170
- fields: updFields,
171
- });
332
+ try {
333
+ for (var s = 0; s < stale.length; s += 1) {
334
+ await client.claude.pushWithUpdateSet({
335
+ update_set_sys_id: params.updateSetSysId,
336
+ table: "sys_choice",
337
+ record_sys_id: stale[s].sys_id,
338
+ fields: updFields,
339
+ });
340
+ }
341
+ }
342
+ catch (e) {
343
+ throw new ChoiceWriteError("add-choices", choice.value, results, e);
344
+ }
172
345
  results.push({
173
346
  value: choice.value,
174
347
  label: choice.label,
175
- sysId: match.sys_id,
348
+ sysId: matchSysIds[0],
349
+ sysIds: matchSysIds,
176
350
  action: "updated",
177
351
  });
178
352
  continue;
179
353
  }
180
- var created = await client.claude.createRecord({
181
- table: "sys_choice",
182
- fields: buildChoiceFields(params.table, params.column, choice, dict.sys_scope),
183
- scope: scopeName,
184
- update_set_sys_id: params.updateSetSysId,
185
- });
354
+ var created;
355
+ try {
356
+ created = await client.claude.createRecord({
357
+ table: "sys_choice",
358
+ fields: buildChoiceFields(params.table, params.column, choice, dict.sys_scope),
359
+ scope: scopeName,
360
+ update_set_sys_id: params.updateSetSysId,
361
+ });
362
+ }
363
+ catch (e) {
364
+ throw new ChoiceWriteError("add-choices", choice.value, results, e);
365
+ }
186
366
  results.push({
187
367
  value: choice.value,
188
368
  label: choice.label,
189
369
  sysId: created.sys_id,
370
+ sysIds: [created.sys_id],
190
371
  action: "created",
191
372
  });
192
373
  }
@@ -201,3 +382,107 @@ async function addChoicesToField(client, params) {
201
382
  choices: results,
202
383
  };
203
384
  }
385
+ /**
386
+ * Soft-delete choice values for a field: set `inactive=true` on each matching
387
+ * sys_choice row via pushWithUpdateSet. NEVER a hard delete — the row stays, so the
388
+ * change is reversible and the historical value still resolves on records that
389
+ * already hold it. (A hard drop of sys_choice is deliberately deferred; see DEV-511.)
390
+ *
391
+ * Idempotent:
392
+ * - active value -> "deactivated" (every live row for it flipped to inactive —
393
+ * usually one write, but more when the field holds duplicates)
394
+ * - already inactive -> "unchanged" (no write)
395
+ * - value not found -> "missing" (no write)
396
+ *
397
+ * The dictionary row is fetched only to PROVE the field exists — without that guard a
398
+ * mistyped column reports every value as "missing", the silent-failure this family
399
+ * exists to catch. sys_dictionary.choice is left alone on purpose: removing values
400
+ * does not un-make the column a choice field.
401
+ *
402
+ * Repeated values are collapsed before the loop, so `["a", "a"]` costs no more than
403
+ * `["a"]` and yields one result row — an idempotent verb must not depend on the caller
404
+ * de-duplicating first. (That is "no EXTRA writes from repeats", not "exactly one
405
+ * write": a single value still takes one write per live duplicate row on the field.)
406
+ *
407
+ * LIMITATION — inherited choices. Matching is scoped to `sys_choice.name = <table>`, so
408
+ * a value defined on a PARENT table (task.state inherited by a child) reports "missing"
409
+ * rather than being deactivated. Hiding an inherited choice on a child table needs an
410
+ * override row on the child, which this verb does not write. For the x_cadso_* tables
411
+ * this family targets that case does not arise; on extended OOB tables, treat a
412
+ * surprising "missing" as a signal to check the parent.
413
+ */
414
+ async function removeChoicesFromField(client, params) {
415
+ if (!params.updateSetSysId) {
416
+ throw new Error("updateSetSysId is required — every write must be captured in a named update set.");
417
+ }
418
+ if (!params.values || params.values.length === 0) {
419
+ throw new Error("values must be a non-empty array.");
420
+ }
421
+ var language = params.language || "en";
422
+ // fetchDictionary throws a clear error when the field does not exist; fetchUpdateSet
423
+ // throws unless the set is in progress. Both mirror the add path's guards.
424
+ var dict = await fetchDictionary(client, params.table, params.column);
425
+ var updateSet = await fetchUpdateSet(client, params.updateSetSysId);
426
+ // Collapse repeats: without this a duplicated value writes twice and is counted
427
+ // twice in the summary, which contradicts the idempotency contract.
428
+ var values = dedupe(params.values);
429
+ // Read only the values being removed — see fetchExistingChoices on why the read is
430
+ // scoped to the request rather than the whole field.
431
+ var existing = await fetchExistingChoices(client, params.table, params.column, values);
432
+ var existingByValue = groupExistingByKey(existing);
433
+ var results = [];
434
+ for (var i = 0; i < values.length; i += 1) {
435
+ var value = values[i];
436
+ var matches = existingByValue[language + "::" + value] || [];
437
+ if (matches.length === 0) {
438
+ results.push({ value: value, sysId: "", sysIds: [], action: "missing" });
439
+ continue;
440
+ }
441
+ var sysIds = matches.map(function (row) {
442
+ return row.sys_id;
443
+ });
444
+ // Deactivate EVERY live row for this value. A field can hold more than one, and
445
+ // stopping at the first leaves the choice selectable while reporting success.
446
+ var active = matches.filter(function (row) {
447
+ return row.inactive !== "true";
448
+ });
449
+ if (active.length === 0) {
450
+ results.push({
451
+ value: value,
452
+ sysId: sysIds[0],
453
+ sysIds: sysIds,
454
+ action: "unchanged",
455
+ });
456
+ continue;
457
+ }
458
+ try {
459
+ for (var a = 0; a < active.length; a += 1) {
460
+ await client.claude.pushWithUpdateSet({
461
+ update_set_sys_id: params.updateSetSysId,
462
+ table: "sys_choice",
463
+ record_sys_id: active[a].sys_id,
464
+ fields: { inactive: "true" },
465
+ });
466
+ }
467
+ }
468
+ catch (e) {
469
+ throw new ChoiceWriteError("remove-choices", value, results, e);
470
+ }
471
+ results.push({
472
+ value: value,
473
+ sysId: sysIds[0],
474
+ sysIds: sysIds,
475
+ action: "deactivated",
476
+ });
477
+ }
478
+ return {
479
+ field: {
480
+ table: params.table,
481
+ column: params.column,
482
+ language: language,
483
+ dictionarySysId: dict.sys_id,
484
+ },
485
+ updateSet: { sysId: updateSet.sys_id, name: updateSet.name },
486
+ choices: results,
487
+ };
488
+ }
package/dist/cli.js CHANGED
@@ -69,6 +69,7 @@ const formLayout_1 = require("./layout/formLayout");
69
69
  const relatedLists_1 = require("./layout/relatedLists");
70
70
  const formatter_2 = require("./layout/formatter");
71
71
  const server_1 = require("./mcp/server");
72
+ const schemas_1 = require("./mcp/schemas");
72
73
  const buildFlowOrchestrator_1 = require("./flowDesigner/buildFlowOrchestrator");
73
74
  const flowDesigner_formatter_1 = require("./flowDesigner-formatter");
74
75
  const readFlow_1 = require("./flowDesigner/readFlow");
@@ -147,15 +148,104 @@ function paramsFromFlags(flags) {
147
148
  }
148
149
  return params;
149
150
  }
150
- async function runAddChoices(flags) {
151
+ /**
152
+ * A string flag whose value was forgotten arrives as the literal "true" (see
153
+ * ParsedArgs.bare). Booleans legitimately do that, strings never do — so refuse
154
+ * rather than write nonsense. Returns the message to print, or null when clean.
155
+ */
156
+ function bareStringFlagError(verb, bare, stringFlags) {
157
+ for (var f = 0; f < stringFlags.length; f += 1) {
158
+ if (bare[stringFlags[f]]) {
159
+ return (verb + ": --" + stringFlags[f] + " needs a value (it was given none).\n");
160
+ }
161
+ }
162
+ return null;
163
+ }
164
+ async function runAddChoices(flags, bare) {
165
+ // `updateSetSysId` is guarded alongside `update-set` because paramsFromFlags accepts
166
+ // both spellings — guarding only the dashed one leaves the alias as a way in.
167
+ var bareErr = bareStringFlagError("add-choices", bare, [
168
+ "table",
169
+ "column",
170
+ "update-set",
171
+ "updateSetSysId",
172
+ "choices",
173
+ "from-json",
174
+ "choice-type",
175
+ ]);
176
+ if (bareErr) {
177
+ process.stderr.write(bareErr);
178
+ return 1;
179
+ }
151
180
  var params = paramsFromFlags(flags);
152
181
  var client = (0, client_1.createClient)({});
153
182
  var result = await (0, choices_1.addChoicesToField)(client, params);
154
183
  if (flags.json === "true") {
155
184
  process.stdout.write(JSON.stringify({ params: params, result: result }, null, 2) + "\n");
156
- return;
185
+ return 0;
157
186
  }
158
187
  process.stdout.write((0, formatter_1.formatAddChoicesResult)(params.table, params.column, result) + "\n");
188
+ return 0;
189
+ }
190
+ function removeParamsFromFlags(flags) {
191
+ if (flags["from-json"]) {
192
+ var raw = fs.readFileSync(flags["from-json"], "utf8");
193
+ // Validate rather than cast: the same zod schema the MCP tool parses with, so a
194
+ // malformed spec fails here with a field-level message instead of somewhere
195
+ // downstream as an undefined table name.
196
+ return schemas_1.removeChoicesFromFieldSchema.parse(JSON.parse(raw));
197
+ }
198
+ var table = flags.table;
199
+ var column = flags.column;
200
+ var updateSetSysId = flags["update-set"] || flags.updateSetSysId;
201
+ var valuesInline = flags.values;
202
+ if (!table || !column || !updateSetSysId || !valuesInline) {
203
+ throw new Error("Missing required flags: --table, --column, --update-set, --values");
204
+ }
205
+ var params = {
206
+ table: table,
207
+ column: column,
208
+ updateSetSysId: updateSetSysId,
209
+ values: valuesInline
210
+ .split(",")
211
+ .map(function (v) {
212
+ return v.trim();
213
+ })
214
+ .filter(function (v) {
215
+ return v.length > 0;
216
+ }),
217
+ };
218
+ if (flags.language) {
219
+ params.language = flags.language;
220
+ }
221
+ return params;
222
+ }
223
+ async function runRemoveChoices(flags, bare) {
224
+ // --language matters most here: a bare one makes every lookup key "true::<value>",
225
+ // so every value reports "missing", nothing is written, and the summary still reads
226
+ // like a clean run. That is the silent failure this verb family exists to catch.
227
+ var bareErr = bareStringFlagError("remove-choices", bare, [
228
+ "table",
229
+ "column",
230
+ "update-set",
231
+ "updateSetSysId",
232
+ "values",
233
+ "language",
234
+ "from-json",
235
+ ]);
236
+ if (bareErr) {
237
+ process.stderr.write(bareErr);
238
+ return 1;
239
+ }
240
+ var params = removeParamsFromFlags(flags);
241
+ var client = (0, client_1.createClient)({});
242
+ var result = await (0, choices_1.removeChoicesFromField)(client, params);
243
+ if (flags.json === "true") {
244
+ process.stdout.write(JSON.stringify({ params: params, result: result }, null, 2) + "\n");
245
+ return 0;
246
+ }
247
+ process.stdout.write((0, formatter_1.formatRemoveChoicesResult)(params.table, params.column, result) + "\n");
248
+ return 0;
159
249
  }
160
250
  /**
161
251
  * dove-sn build-flow:
@@ -850,6 +940,9 @@ function printHelp() {
850
940
  process.stdout.write("dove-sn — ServiceNow platform helpers\n\n" +
851
941
  "Commands:\n" +
852
942
  " add-choices Upsert sys_choice rows for a table.column\n" +
943
+ " remove-choices Soft-delete (inactive=true) sys_choice values for a table.column\n" +
944
+ " (--table <t> --column <c> --values a,b,c --update-set <sys_id>\n" +
945
+ " [--language en] [--from-json <path>] [--json])\n" +
853
946
  " create-view Create a custom view (sys_ui_view)\n" +
854
947
  " (--name <n> --update-set <sys_id> [--title <t>] [--scope <s>] [--dry-run] [--json])\n" +
855
948
  " set-list-layout Set the columns of a list layout\n" +
@@ -1149,17 +1242,17 @@ function parseBoolFlag(name, raw, verb = "set-column") {
1149
1242
  * to set a RECORD's value, use set-field.
1150
1243
  */
1151
1244
  async function runSetColumn(flags, bare) {
1152
- // A string flag whose value was forgotten arrives as the literal "true" — `--label`
1153
- // with nothing after it would rename the column to "true". Booleans legitimately do
1154
- // that, strings never do, so refuse rather than silently write nonsense.
1155
- var stringFlags = ["label", "default", "table", "column", "update-set"];
1156
- for (var f = 0; f < stringFlags.length; f += 1) {
1157
- if (bare[stringFlags[f]]) {
1158
- process.stderr.write("set-column: --" +
1159
- stringFlags[f] +
1160
- " needs a value (it was given none).\n");
1161
- return 1;
1162
- }
1245
+ var bareErr = bareStringFlagError("set-column", bare, [
1246
+ "label",
1247
+ "default",
1248
+ "table",
1249
+ "column",
1250
+ "update-set",
1251
+ "updateSetSysId",
1252
+ ]);
1253
+ if (bareErr) {
1254
+ process.stderr.write(bareErr);
1255
+ return 1;
1163
1256
  }
1164
1257
  var table = flags.table;
1165
1258
  var column = flags.column;
@@ -1726,8 +1819,10 @@ async function main() {
1726
1819
  // target multiple instances; otherwise the cwd `.env` is used.
1727
1820
  (0, loadEnv_1.loadEnvFile)(parsed.flags.env || parsed.flags["env-file"]);
1728
1821
  if (parsed.command === "add-choices") {
1729
- await runAddChoices(parsed.flags);
1730
- return 0;
1822
+ return await runAddChoices(parsed.flags, parsed.bare);
1823
+ }
1824
+ if (parsed.command === "remove-choices") {
1825
+ return await runRemoveChoices(parsed.flags, parsed.bare);
1731
1826
  }
1732
1827
  if (parsed.command === "build-flow") {
1733
1828
  return await runBuildFlowCmd(parsed.flags);
@@ -0,0 +1,84 @@
1
+ /**
2
+ * S1 — Execution-context detection + two-phase gate (schema-CRUD RFC §7).
3
+ *
4
+ * The reusable gate every schema-mutating verb runs through before it writes. It
5
+ * answers one question, given HOW the command was invoked:
6
+ *
7
+ * - Local / interactive → two-phase. A schema mutation writes only on explicit
8
+ * confirmation, after the caller has seen the dry-run plan.
9
+ * - Automation / CI → a DESTRUCTIVE change is allowed only if it arrived
10
+ * through a merged pull request: the workflow passes a merge ref AND proof the
11
+ * change is present in that merged diff. Missing either → refuse. This is the
12
+ * §7.1 rule: an unattended run may not ORIGINATE a destructive schema change.
13
+ *
14
+ * This is NOT a security boundary — a caller can construct any input it likes. It is
15
+ * a guard rail that makes the safe path the default and the dangerous path explicit,
16
+ * legibly, in one place instead of re-derived per verb. It FAILS CLOSED: when the
17
+ * context is ambiguous it resolves to `local` (confirm-required), and an automation
18
+ * destructive write with no valid merge signal is refused (RFC R4 — the gate is only
19
+ * as good as the signal the workflow passes, so absence or falsity both refuse).
20
+ *
21
+ * ES6 only; no optional chaining. Pure logic — no ServiceNow I/O — so it is unit
22
+ * tested exhaustively and carries no live-instance risk.
23
+ */
24
+ export type ExecutionContext = "local" | "automation";
25
+ export interface ResolveContextInput {
26
+ /** Explicit override (e.g. from a `--context` flag). Wins over auto-detection. */
27
+ override?: string;
28
+ /**
29
+ * Environment to read the CI signal from. Injected for tests; defaults to
30
+ * process.env. Kept explicit so detection is deterministic and testable.
31
+ */
32
+ env?: Record<string, string | undefined>;
33
+ }
34
+ /**
35
+ * Whether we appear to be running in a CI/automation environment. Reads the standard
36
+ * signals — GitHub Actions sets GITHUB_ACTIONS=true; CI=true is the generic marker
37
+ * most runners set. Necessary but NOT sufficient to allow a destructive write: "ran
38
+ * in CI" ≠ "came from a merged PR" (that is the merge-signal check in the gate).
39
+ */
40
+ export declare function isCiEnvironment(env: Record<string, string | undefined>): boolean;
41
+ /**
42
+ * Resolve local-vs-automation. An explicit override always wins (and an override that
43
+ * is neither 'local' nor 'automation' is a hard error, not a silent fallback). With
44
+ * no override, auto-detect CI; default to `local` — the safer, confirm-required
45
+ * branch — when there is no CI signal.
46
+ */
47
+ export declare function resolveExecutionContext(input?: ResolveContextInput): ExecutionContext;
48
+ /**
49
+ * The merged-PR signal an automation workflow passes for a destructive change.
50
+ *
51
+ * `changePresent` is the load-bearing field: the workflow is responsible for proving
52
+ * the destructive change is actually in the diff at `mergeRef`, and the gate fails
53
+ * closed unless it is explicitly true. Passing a ref alone is not enough — that would
54
+ * let any CI run assert "a PR merged somewhere", which is exactly the loophole §7.1
55
+ * closes.
56
+ */
57
+ export interface MergeSignal {
58
+ /** The merge commit SHA or PR ref the automation ran from. */
59
+ mergeRef: string;
60
+ /** Proof the destructive change is present in the diff at `mergeRef`. */
61
+ changePresent: boolean;
62
+ }
63
+ export interface WriteGateInput {
64
+ context: ExecutionContext;
65
+ /** True for a WRITE_DESTRUCTIVE operation (drop table/column). */
66
+ destructive: boolean;
67
+ /** Local: explicit confirmation that the dry-run plan was reviewed and accepted. */
68
+ confirmed?: boolean;
69
+ /** Automation: the merged-PR signal, required for a destructive change. */
70
+ mergeSignal?: MergeSignal;
71
+ }
72
+ export interface GateDecision {
73
+ allowed: boolean;
74
+ /** Why the write was refused, and what to do — empty string when allowed. */
75
+ reason: string;
76
+ }
77
+ /**
78
+ * Decide whether a schema write may proceed. Returns a decision rather than throwing,
79
+ * so a verb can fold `reason` into its own structured result; `assertWriteAllowed` is
80
+ * the throwing wrapper for callers that prefer to abort.
81
+ */
82
+ export declare function evaluateWriteGate(input: WriteGateInput): GateDecision;
83
+ /** Throwing wrapper around evaluateWriteGate for verbs that prefer to abort on refusal. */
84
+ export declare function assertWriteAllowed(input: WriteGateInput): void;
@@ -0,0 +1,105 @@
1
+ "use strict";
2
+ /**
3
+ * S1 — Execution-context detection + two-phase gate (schema-CRUD RFC §7).
4
+ *
5
+ * The reusable gate every schema-mutating verb runs through before it writes. It
6
+ * answers one question, given HOW the command was invoked:
7
+ *
8
+ * - Local / interactive → two-phase. A schema mutation writes only on explicit
9
+ * confirmation, after the caller has seen the dry-run plan.
10
+ * - Automation / CI → a DESTRUCTIVE change is allowed only if it arrived
11
+ * through a merged pull request: the workflow passes a merge ref AND proof the
12
+ * change is present in that merged diff. Missing either → refuse. This is the
13
+ * §7.1 rule: an unattended run may not ORIGINATE a destructive schema change.
14
+ *
15
+ * This is NOT a security boundary — a caller can construct any input it likes. It is
16
+ * a guard rail that makes the safe path the default and the dangerous path explicit,
17
+ * legibly, in one place instead of re-derived per verb. It FAILS CLOSED: when the
18
+ * context is ambiguous it resolves to `local` (confirm-required), and an automation
19
+ * destructive write with no valid merge signal is refused (RFC R4 — the gate is only
20
+ * as good as the signal the workflow passes, so absence or falsity both refuse).
21
+ *
22
+ * ES6 only; no optional chaining. Pure logic — no ServiceNow I/O — so it is unit
23
+ * tested exhaustively and carries no live-instance risk.
24
+ */
25
+ Object.defineProperty(exports, "__esModule", { value: true });
26
+ exports.isCiEnvironment = isCiEnvironment;
27
+ exports.resolveExecutionContext = resolveExecutionContext;
28
+ exports.evaluateWriteGate = evaluateWriteGate;
29
+ exports.assertWriteAllowed = assertWriteAllowed;
30
+ /**
31
+ * Whether we appear to be running in a CI/automation environment. Reads the standard
32
+ * signals — GitHub Actions sets GITHUB_ACTIONS=true; CI=true is the generic marker
33
+ * most runners set. Necessary but NOT sufficient to allow a destructive write: "ran
34
+ * in CI" ≠ "came from a merged PR" (that is the merge-signal check in the gate).
35
+ */
36
+ function isCiEnvironment(env) {
37
+ return env.GITHUB_ACTIONS === "true" || env.CI === "true" || env.CI === "1";
38
+ }
39
+ /**
40
+ * Resolve local-vs-automation. An explicit override always wins (and an override that
41
+ * is neither 'local' nor 'automation' is a hard error, not a silent fallback). With
42
+ * no override, auto-detect CI; default to `local` — the safer, confirm-required
43
+ * branch — when there is no CI signal.
44
+ */
45
+ function resolveExecutionContext(input = {}) {
46
+ var override = input.override
47
+ ? String(input.override).trim().toLowerCase()
48
+ : "";
49
+ if (override === "local" || override === "automation") {
50
+ return override;
51
+ }
52
+ if (override) {
53
+ throw new Error("context must be 'local' or 'automation', got '" + input.override + "'.");
54
+ }
55
+ var env = input.env || process.env;
56
+ return isCiEnvironment(env) ? "automation" : "local";
57
+ }
58
+ /**
59
+ * Decide whether a schema write may proceed. Returns a decision rather than throwing,
60
+ * so a verb can fold `reason` into its own structured result; `assertWriteAllowed` is
61
+ * the throwing wrapper for callers that prefer to abort.
62
+ */
63
+ function evaluateWriteGate(input) {
64
+ if (input.context === "local") {
65
+ // Every schema mutation is two-phase locally — destructive or not.
66
+ if (!input.confirmed) {
67
+ return {
68
+ allowed: false,
69
+ reason: "Local schema writes are two-phase: review the dry-run plan, then re-run " +
70
+ "with confirmation. Nothing was written.",
71
+ };
72
+ }
73
+ return { allowed: true, reason: "" };
74
+ }
75
+ // Automation. A non-destructive write may run unattended; a destructive one may not
76
+ // ORIGINATE here — it must have arrived through a merged PR.
77
+ if (!input.destructive) {
78
+ return { allowed: true, reason: "" };
79
+ }
80
+ if (!input.mergeSignal || !input.mergeSignal.mergeRef) {
81
+ return {
82
+ allowed: false,
83
+ reason: "Automation refuses a destructive schema change without a merged-PR signal " +
84
+ "— an unattended run may not originate one (fail closed).",
85
+ };
86
+ }
87
+ if (!input.mergeSignal.changePresent) {
88
+ return {
89
+ allowed: false,
90
+ reason: "The destructive change is not present in the merged diff '" +
91
+ input.mergeSignal.mergeRef +
92
+ "' — refusing (fail closed).",
93
+ };
94
+ }
95
+ return { allowed: true, reason: "" };
96
+ }
97
+ /** Throwing wrapper around evaluateWriteGate for verbs that prefer to abort on refusal. */
98
+ function assertWriteAllowed(input) {
99
+ var decision = evaluateWriteGate(input);
100
+ if (!decision.allowed) {
101
+ // No verb prefix — the reason is self-contained, and this gate is shared by every
102
+ // schema verb (remove-field/remove-table/…), none of which owns the message.
103
+ throw new Error(decision.reason);
104
+ }
105
+ }
@@ -1,6 +1,12 @@
1
- import type { AddChoicesResult } from "./types";
1
+ import type { AddChoicesResult, RemoveChoicesResult } from "./types";
2
2
  /**
3
3
  * Human-readable one-page summary of an addChoicesToField result.
4
4
  * Used by the CLI and by Claude skills when surfacing outcomes back to the user.
5
5
  */
6
6
  export declare function formatAddChoicesResult(table: string, column: string, result: AddChoicesResult): string;
7
+ /**
8
+ * Human-readable one-page summary of a removeChoicesFromField result.
9
+ * Soft-delete semantics: "deactivated" set inactive=true; "unchanged" was already
10
+ * inactive; "missing" was not found on the field.
11
+ */
12
+ export declare function formatRemoveChoicesResult(table: string, column: string, result: RemoveChoicesResult): string;
package/dist/formatter.js CHANGED
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.formatAddChoicesResult = formatAddChoicesResult;
4
+ exports.formatRemoveChoicesResult = formatRemoveChoicesResult;
4
5
  /**
5
6
  * Human-readable one-page summary of an addChoicesToField result.
6
7
  * Used by the CLI and by Claude skills when surfacing outcomes back to the user.
@@ -36,3 +37,67 @@ function formatAddChoicesResult(table, column, result) {
36
37
  lines.push("Summary: " + created + " created, " + updated + " updated, " + unchanged + " unchanged.");
37
38
  return lines.join("\n");
38
39
  }
40
+ /**
41
+ * Human-readable one-page summary of a removeChoicesFromField result.
42
+ * Soft-delete semantics: "deactivated" set inactive=true; "unchanged" was already
43
+ * inactive; "missing" was not found on the field.
44
+ */
45
+ function formatRemoveChoicesResult(table, column, result) {
46
+ var lines = [];
47
+ lines.push("ServiceNow choice soft-delete — " +
48
+ table +
49
+ "." +
50
+ column +
51
+ " [" +
52
+ result.field.language +
53
+ "]");
54
+ lines.push("");
55
+ lines.push("Update set: " +
56
+ result.updateSet.name +
57
+ " (" +
58
+ result.updateSet.sysId +
59
+ ")");
60
+ lines.push("Dictionary: " + result.field.dictionarySysId);
61
+ lines.push("");
62
+ var deactivated = 0;
63
+ var unchanged = 0;
64
+ var missing = 0;
65
+ lines.push("Choices:");
66
+ result.choices.forEach(function (row) {
67
+ if (row.action === "deactivated")
68
+ deactivated += 1;
69
+ else if (row.action === "unchanged")
70
+ unchanged += 1;
71
+ else
72
+ missing += 1;
73
+ // A value with more than one row is worth saying out loud — the field carries
74
+ // duplicates. The wording has to follow the action: on "unchanged" nothing was
75
+ // written, so claiming they were deactivated would contradict the summary below.
76
+ // "all now inactive" rather than "all deactivated": on a mixed set — one duplicate
77
+ // already inactive, one live — only the live row is written, so claiming both were
78
+ // deactivated overstates it. End state is what the reader needs, and it is true in
79
+ // both cases.
80
+ var note = "";
81
+ if (row.sysIds.length > 1) {
82
+ note =
83
+ row.action === "deactivated"
84
+ ? " [" + row.sysIds.length + " duplicate rows, all now inactive]"
85
+ : " [" + row.sysIds.length + " duplicate rows, all already inactive]";
86
+ }
87
+ lines.push(" [" +
88
+ row.action.padEnd(11) +
89
+ "] " +
90
+ row.value +
91
+ (row.sysId ? " (" + row.sysId + ")" : "") +
92
+ note);
93
+ });
94
+ lines.push("");
95
+ lines.push("Summary: " +
96
+ deactivated +
97
+ " deactivated, " +
98
+ unchanged +
99
+ " unchanged, " +
100
+ missing +
101
+ " missing.");
102
+ return lines.join("\n");
103
+ }
package/dist/index.d.ts CHANGED
@@ -5,11 +5,13 @@
5
5
  * REST API so every change lands in the target update set and scope.
6
6
  */
7
7
  export { createClient } from "./client";
8
+ export { resolveExecutionContext, isCiEnvironment, evaluateWriteGate, assertWriteAllowed, } from "./executionContext";
9
+ export type { ExecutionContext, ResolveContextInput, MergeSignal, WriteGateInput, GateDecision, } from "./executionContext";
8
10
  export { createClientFromEnvFile, resolveConfigFromEnvFile, } from "./createClientFromEnvFile";
9
11
  export type { ServiceNowClient, TableQueryOptions, TableSchema, TableSchemaField, AttachmentMeta, NowInvokeMethod, NowInvokeParams, NowInvokeResponse, } from "./client";
10
- export { addChoicesToField } from "./choices";
12
+ export { addChoicesToField, removeChoicesFromField, ChoiceWriteError, } from "./choices";
11
13
  export { hostAssets, classifyChunks, formatHostAssetsResult, } from "./hostAssets";
12
- export { formatAddChoicesResult } from "./formatter";
14
+ export { formatAddChoicesResult, formatRemoveChoicesResult } from "./formatter";
13
15
  export { createView } from "./layout/views";
14
16
  export { setListLayout } from "./layout/listLayout";
15
17
  export { setFormLayout } from "./layout/formLayout";
@@ -18,7 +20,7 @@ export { formatLayoutResult, formatCreateViewResult } from "./layout/formatter";
18
20
  export { sincPlugin } from "./plugin";
19
21
  export { listTemplates, verifyArtifact, cloneSubflow, cloneActionType, triggerPublication, publishActionType, editActionType, applyStepOps, verifySteps, summarizeSteps, formatStepPill, readFlow, readActionType, publishFlow, copyFlow, createFlow, buildPublishModel, editFlow, testFlow, DEFAULT_RUN_FLOW_PATH, generateSysId, topoSort, executeWritePlan, WriteOrderError, } from "./flowDesigner";
20
22
  export type { TemplateRef, ListTemplatesParams, FlowKind, VerifyExpect, VerifyFound, VerifyFailure, VerifyReport, VerifyArtifactParams, CloneSubflowParams, CloneSubflowResult, CloneActionTypeParams, CloneActionTypeResult, TriggerPublicationParams, TriggerPublicationResult, PublishActionTypeParams, PublishActionTypeResult, EditActionTypeParams, EditActionTypeResult, EditActionTypeOps, StepOps, StepRecord, StepSummary, StepIoSummary, PatchStepScriptOp, AddStepOutputOp, AddStepInputOp, ApplyStepOpsResult, VerifyStepsResult, ReadFlowParams, ReadFlowResult, FlowStep, FlowVariable, ReadActionTypeParams, ReadActionTypeResult, ActionIo, PublishFlowParams, PublishFlowResult, CopyFlowParams, CopyFlowResult, CreateFlowParams, CreateFlowResult, EditFlowParams, EditFlowResult, EditFlowOps, StepInputPatch, TestFlowParams, TestFlowResult, WriteOp, WriteOpResult, } from "./flowDesigner";
21
- export type { ServiceNowClientConfig, ChoiceValue, ChoiceType, AddChoicesParams, AddChoicesResult, ChoiceActionResult, DictionaryRecord, UpdateSetRecord, LayoutAction, LayoutRecordResult, LayoutResult, CreateViewParams, CreateViewResult, FormSectionSpec, SetFormLayoutParams, SetListLayoutParams, SetRelatedListsParams, ChunkRole, ChunkInfo, ChunkResult, PrunedResult, HostAssetsParams, HostAssetsResult, } from "./types";
23
+ export type { ServiceNowClientConfig, ChoiceValue, ChoiceType, AddChoicesParams, AddChoicesResult, ChoiceActionResult, RemoveChoicesParams, RemoveChoicesResult, ChoiceRemovalResult, DictionaryRecord, UpdateSetRecord, LayoutAction, LayoutRecordResult, LayoutResult, CreateViewParams, CreateViewResult, FormSectionSpec, SetFormLayoutParams, SetListLayoutParams, SetRelatedListsParams, ChunkRole, ChunkInfo, ChunkResult, PrunedResult, HostAssetsParams, HostAssetsResult, } from "./types";
22
24
  export { createTable, projectTableGraph, buildColumnXml, normalizeColumns, resolveType, applyTableSaveOverlay, defaultAccessFlags, TYPE_MAP, DEFAULT_SUPER_CLASS, DEFAULT_SAVE_ACTION, addColumn, deriveElement, setColumn, resolveAttributes, toStoredValue, setTable, resolveTableAttributes, } from "./table";
23
25
  export type { CreateTableParams, CreateTableResult, AddColumnParams, AddColumnResult, SetColumnParams, SetColumnResult, ColumnAttributes, AttributeChange, SetTableParams, SetTableResult, TableAttributes, TableGraph, NormalizedColumn, ColumnSpec, AccessFlags, OverlaySpec, } from "./table";
24
26
  export { setField } from "./setField";
package/dist/index.js CHANGED
@@ -6,21 +6,29 @@
6
6
  * REST API so every change lands in the target update set and scope.
7
7
  */
8
8
  Object.defineProperty(exports, "__esModule", { value: true });
9
- exports.addColumn = exports.DEFAULT_SAVE_ACTION = exports.DEFAULT_SUPER_CLASS = exports.TYPE_MAP = exports.defaultAccessFlags = exports.applyTableSaveOverlay = exports.resolveType = exports.normalizeColumns = exports.buildColumnXml = exports.projectTableGraph = exports.createTable = exports.WriteOrderError = exports.executeWritePlan = exports.topoSort = exports.generateSysId = exports.DEFAULT_RUN_FLOW_PATH = exports.testFlow = exports.editFlow = exports.buildPublishModel = exports.createFlow = exports.copyFlow = exports.publishFlow = exports.readActionType = exports.readFlow = exports.formatStepPill = exports.summarizeSteps = exports.verifySteps = exports.applyStepOps = exports.editActionType = exports.publishActionType = exports.triggerPublication = exports.cloneActionType = exports.cloneSubflow = exports.verifyArtifact = exports.listTemplates = exports.sincPlugin = exports.formatCreateViewResult = exports.formatLayoutResult = exports.setRelatedLists = exports.setFormLayout = exports.setListLayout = exports.createView = exports.formatAddChoicesResult = exports.formatHostAssetsResult = exports.classifyChunks = exports.hostAssets = exports.addChoicesToField = exports.resolveConfigFromEnvFile = exports.createClientFromEnvFile = exports.createClient = void 0;
10
- exports.PUBLISH_POLL_DELAYS_MS = exports.DEFAULT_PUBLISH_TIMEOUT_MS = exports.parseCicdProgress = exports.parseCicdPublishResponse = exports.harvestProgressResults = exports.flattenSteps = exports.classifyProgress = exports.parseProgressTree = exports.parseXmlAnswer = exports.buildStartFields = exports.publishApp = exports.INVOKE_REST_METHODS = exports.invokeRest = exports.createRecord = exports.setField = exports.resolveTableAttributes = exports.setTable = exports.toStoredValue = exports.resolveAttributes = exports.setColumn = exports.deriveElement = void 0;
9
+ exports.normalizeColumns = exports.buildColumnXml = exports.projectTableGraph = exports.createTable = exports.WriteOrderError = exports.executeWritePlan = exports.topoSort = exports.generateSysId = exports.DEFAULT_RUN_FLOW_PATH = exports.testFlow = exports.editFlow = exports.buildPublishModel = exports.createFlow = exports.copyFlow = exports.publishFlow = exports.readActionType = exports.readFlow = exports.formatStepPill = exports.summarizeSteps = exports.verifySteps = exports.applyStepOps = exports.editActionType = exports.publishActionType = exports.triggerPublication = exports.cloneActionType = exports.cloneSubflow = exports.verifyArtifact = exports.listTemplates = exports.sincPlugin = exports.formatCreateViewResult = exports.formatLayoutResult = exports.setRelatedLists = exports.setFormLayout = exports.setListLayout = exports.createView = exports.formatRemoveChoicesResult = exports.formatAddChoicesResult = exports.formatHostAssetsResult = exports.classifyChunks = exports.hostAssets = exports.ChoiceWriteError = exports.removeChoicesFromField = exports.addChoicesToField = exports.resolveConfigFromEnvFile = exports.createClientFromEnvFile = exports.assertWriteAllowed = exports.evaluateWriteGate = exports.isCiEnvironment = exports.resolveExecutionContext = exports.createClient = void 0;
10
+ exports.PUBLISH_POLL_DELAYS_MS = exports.DEFAULT_PUBLISH_TIMEOUT_MS = exports.parseCicdProgress = exports.parseCicdPublishResponse = exports.harvestProgressResults = exports.flattenSteps = exports.classifyProgress = exports.parseProgressTree = exports.parseXmlAnswer = exports.buildStartFields = exports.publishApp = exports.INVOKE_REST_METHODS = exports.invokeRest = exports.createRecord = exports.setField = exports.resolveTableAttributes = exports.setTable = exports.toStoredValue = exports.resolveAttributes = exports.setColumn = exports.deriveElement = exports.addColumn = exports.DEFAULT_SAVE_ACTION = exports.DEFAULT_SUPER_CLASS = exports.TYPE_MAP = exports.defaultAccessFlags = exports.applyTableSaveOverlay = exports.resolveType = void 0;
11
11
  var client_1 = require("./client");
12
12
  Object.defineProperty(exports, "createClient", { enumerable: true, get: function () { return client_1.createClient; } });
13
+ var executionContext_1 = require("./executionContext");
14
+ Object.defineProperty(exports, "resolveExecutionContext", { enumerable: true, get: function () { return executionContext_1.resolveExecutionContext; } });
15
+ Object.defineProperty(exports, "isCiEnvironment", { enumerable: true, get: function () { return executionContext_1.isCiEnvironment; } });
16
+ Object.defineProperty(exports, "evaluateWriteGate", { enumerable: true, get: function () { return executionContext_1.evaluateWriteGate; } });
17
+ Object.defineProperty(exports, "assertWriteAllowed", { enumerable: true, get: function () { return executionContext_1.assertWriteAllowed; } });
13
18
  var createClientFromEnvFile_1 = require("./createClientFromEnvFile");
14
19
  Object.defineProperty(exports, "createClientFromEnvFile", { enumerable: true, get: function () { return createClientFromEnvFile_1.createClientFromEnvFile; } });
15
20
  Object.defineProperty(exports, "resolveConfigFromEnvFile", { enumerable: true, get: function () { return createClientFromEnvFile_1.resolveConfigFromEnvFile; } });
16
21
  var choices_1 = require("./choices");
17
22
  Object.defineProperty(exports, "addChoicesToField", { enumerable: true, get: function () { return choices_1.addChoicesToField; } });
23
+ Object.defineProperty(exports, "removeChoicesFromField", { enumerable: true, get: function () { return choices_1.removeChoicesFromField; } });
24
+ Object.defineProperty(exports, "ChoiceWriteError", { enumerable: true, get: function () { return choices_1.ChoiceWriteError; } });
18
25
  var hostAssets_1 = require("./hostAssets");
19
26
  Object.defineProperty(exports, "hostAssets", { enumerable: true, get: function () { return hostAssets_1.hostAssets; } });
20
27
  Object.defineProperty(exports, "classifyChunks", { enumerable: true, get: function () { return hostAssets_1.classifyChunks; } });
21
28
  Object.defineProperty(exports, "formatHostAssetsResult", { enumerable: true, get: function () { return hostAssets_1.formatHostAssetsResult; } });
22
29
  var formatter_1 = require("./formatter");
23
30
  Object.defineProperty(exports, "formatAddChoicesResult", { enumerable: true, get: function () { return formatter_1.formatAddChoicesResult; } });
31
+ Object.defineProperty(exports, "formatRemoveChoicesResult", { enumerable: true, get: function () { return formatter_1.formatRemoveChoicesResult; } });
24
32
  var views_1 = require("./layout/views");
25
33
  Object.defineProperty(exports, "createView", { enumerable: true, get: function () { return views_1.createView; } });
26
34
  var listLayout_1 = require("./layout/listLayout");
@@ -10,7 +10,7 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
10
10
  import { z } from "zod";
11
11
  import type { ToolAnnotations } from "@tenonhq/dovetail-mcp-kit";
12
12
  import type { ServiceNowClient } from "../client";
13
- export declare var TOOL_NAMES: readonly ["create_view", "set_list_layout", "set_form_layout", "set_related_lists", "add_choices_to_field", "flow_view", "action_view", "action_edit", "flow_publish", "flow_copy", "flow_create", "flow_test", "flow_edit", "create_table", "add_column", "set_column", "set_table", "set_field", "create_record", "host_assets", "invoke_rest", "app_publish"];
13
+ export declare var TOOL_NAMES: readonly ["create_view", "set_list_layout", "set_form_layout", "set_related_lists", "add_choices_to_field", "remove_choices_from_field", "flow_view", "action_view", "action_edit", "flow_publish", "flow_copy", "flow_create", "flow_test", "flow_edit", "create_table", "add_column", "set_column", "set_table", "set_field", "create_record", "host_assets", "invoke_rest", "app_publish"];
14
14
  export type ToolName = (typeof TOOL_NAMES)[number];
15
15
  export interface RegistryDeps {
16
16
  /** Optional client injection for tests; defaults to createClient({}). */
@@ -39,6 +39,7 @@ exports.TOOL_NAMES = [
39
39
  "set_form_layout",
40
40
  "set_related_lists",
41
41
  "add_choices_to_field",
42
+ "remove_choices_from_field",
42
43
  "flow_view",
43
44
  "action_view",
44
45
  "action_edit",
@@ -120,6 +121,25 @@ function buildDescriptors(deps = {}) {
120
121
  return (0, choices_1.addChoicesToField)(client(), schemas_1.addChoicesToFieldSchema.parse(args));
121
122
  },
122
123
  },
124
+ {
125
+ name: "remove_choices_from_field",
126
+ annotations: dovetail_mcp_kit_1.WRITE_OVERWRITE,
127
+ description: "Soft-delete sys_choice values for a ServiceNow table.column by setting inactive=true " +
128
+ "(the row is kept, so it is reversible and historical values still resolve). Never a " +
129
+ "hard delete. Idempotent: an already-inactive value is 'unchanged', an absent value is " +
130
+ "'missing', and a value repeated in the request causes no extra writes — it collapses " +
131
+ "to one result row. A single value CAN write more than once when the field holds " +
132
+ "duplicate rows for it; every live one is deactivated. sys_dictionary.choice is left " +
133
+ "alone. Matching is scoped by LANGUAGE (defaults to 'en'), so a value that exists only " +
134
+ "in another language reports 'missing' and is left untouched — pass `language` to target " +
135
+ "it. Matching is also scoped to this table, so a choice INHERITED from a parent table " +
136
+ "reports 'missing' rather than being deactivated. Writes are captured in the supplied " +
137
+ "update set.",
138
+ shape: schemas_1.removeChoicesFromFieldSchema.shape,
139
+ handler: async function (args) {
140
+ return (0, choices_1.removeChoicesFromField)(client(), schemas_1.removeChoicesFromFieldSchema.parse(args));
141
+ },
142
+ },
123
143
  {
124
144
  name: "flow_view",
125
145
  annotations: dovetail_mcp_kit_1.READ_ONLY,
@@ -185,6 +185,25 @@ export declare var addChoicesToFieldSchema: z.ZodObject<{
185
185
  column: string;
186
186
  choiceType?: 0 | 1 | 3 | null | undefined;
187
187
  }>;
188
+ export declare var removeChoicesFromFieldSchema: z.ZodObject<{
189
+ table: z.ZodString;
190
+ column: z.ZodString;
191
+ values: z.ZodArray<z.ZodString, "many">;
192
+ language: z.ZodOptional<z.ZodString>;
193
+ updateSetSysId: z.ZodString;
194
+ }, "strip", z.ZodTypeAny, {
195
+ values: string[];
196
+ table: string;
197
+ updateSetSysId: string;
198
+ column: string;
199
+ language?: string | undefined;
200
+ }, {
201
+ values: string[];
202
+ table: string;
203
+ updateSetSysId: string;
204
+ column: string;
205
+ language?: string | undefined;
206
+ }>;
188
207
  export declare var viewFlowSchema: z.ZodObject<{
189
208
  sysId: z.ZodString;
190
209
  raw: z.ZodOptional<z.ZodBoolean>;
@@ -4,7 +4,7 @@
4
4
  * own file so registry.ts stays focused on wiring.
5
5
  */
6
6
  Object.defineProperty(exports, "__esModule", { value: true });
7
- exports.invokeRestSchema = exports.publishAppSchema = exports.createRecordSchema = exports.setFieldSchema = exports.setTableSchema = exports.tableAttributesSchema = exports.setColumnSchema = exports.columnAttributesSchema = exports.addColumnSchema = exports.createTableSchema = exports.columnSpecSchema = exports.hostAssetsSchema = exports.editFlowSchema = exports.editActionSchema = exports.stepInputPatchSchema = exports.testFlowSchema = exports.createFlowSchema = exports.copyFlowSchema = exports.publishFlowSchema = exports.viewActionSchema = exports.viewFlowSchema = exports.addChoicesToFieldSchema = exports.choiceValueSchema = exports.setRelatedListsSchema = exports.setFormLayoutSchema = exports.formSectionSchema = exports.setListLayoutSchema = exports.createViewSchema = void 0;
7
+ exports.invokeRestSchema = exports.publishAppSchema = exports.createRecordSchema = exports.setFieldSchema = exports.setTableSchema = exports.tableAttributesSchema = exports.setColumnSchema = exports.columnAttributesSchema = exports.addColumnSchema = exports.createTableSchema = exports.columnSpecSchema = exports.hostAssetsSchema = exports.editFlowSchema = exports.editActionSchema = exports.stepInputPatchSchema = exports.testFlowSchema = exports.createFlowSchema = exports.copyFlowSchema = exports.publishFlowSchema = exports.viewActionSchema = exports.viewFlowSchema = exports.removeChoicesFromFieldSchema = exports.addChoicesToFieldSchema = exports.choiceValueSchema = exports.setRelatedListsSchema = exports.setFormLayoutSchema = exports.formSectionSchema = exports.setListLayoutSchema = exports.createViewSchema = void 0;
8
8
  const zod_1 = require("zod");
9
9
  exports.createViewSchema = zod_1.z.object({
10
10
  name: zod_1.z.string().min(1),
@@ -61,6 +61,13 @@ exports.addChoicesToFieldSchema = zod_1.z.object({
61
61
  .nullable()
62
62
  .optional(),
63
63
  });
64
+ exports.removeChoicesFromFieldSchema = zod_1.z.object({
65
+ table: zod_1.z.string().min(1),
66
+ column: zod_1.z.string().min(1),
67
+ values: zod_1.z.array(zod_1.z.string().min(1)).min(1),
68
+ language: zod_1.z.string().min(1).optional(),
69
+ updateSetSysId: zod_1.z.string().min(1),
70
+ });
64
71
  exports.viewFlowSchema = zod_1.z.object({
65
72
  sysId: zod_1.z.string().min(1),
66
73
  raw: zod_1.z.boolean().optional(),
package/dist/types.d.ts CHANGED
@@ -54,7 +54,25 @@ export interface UpdateSetRecord {
54
54
  export interface ChoiceActionResult {
55
55
  value: string;
56
56
  label: string;
57
+ /**
58
+ * The FIRST row matching this value — `sysIds[0]`. On "created" it is the new row;
59
+ * on "updated" a duplicate that was already correct is left untouched, so this is
60
+ * not necessarily a row that was written. `sysIds` is the complete picture.
61
+ */
57
62
  sysId: string;
63
+ /**
64
+ * EVERY sys_choice row that matched this language::value — not only the ones written.
65
+ * Normally one, but sys_choice has no uniqueness constraint, so a field can hold
66
+ * duplicates; on "updated" only the rows that actually differ are written, and any
67
+ * duplicate that already matched the spec is left alone. A length > 1 is the caller's
68
+ * signal that the field needs cleaning up.
69
+ *
70
+ * OPTIONAL only for backwards compatibility: this type ships in a published package,
71
+ * and a required field would break any consumer that constructs or mocks one. The
72
+ * runtime always populates it — treat a missing value as "an older build produced
73
+ * this", not as a state addChoicesToField can return.
74
+ */
75
+ sysIds?: Array<string>;
58
76
  action: "created" | "updated" | "unchanged";
59
77
  }
60
78
  export interface AddChoicesResult {
@@ -70,6 +88,58 @@ export interface AddChoicesResult {
70
88
  };
71
89
  choices: Array<ChoiceActionResult>;
72
90
  }
91
+ export interface RemoveChoicesParams {
92
+ /** Target table, e.g. "x_cadso_core_event". */
93
+ table: string;
94
+ /** Target column, e.g. "state". */
95
+ column: string;
96
+ /** Choice values to soft-delete (deactivate). */
97
+ values: Array<string>;
98
+ /** Language of the choices to deactivate. Defaults to "en". */
99
+ language?: string;
100
+ /** Update set sys_id that will capture every write. Required — no default. */
101
+ updateSetSysId: string;
102
+ }
103
+ export interface ChoiceRemovalResult {
104
+ value: string;
105
+ /**
106
+ * The FIRST row matching this value — `sysIds[0]`, or "" when the value was not
107
+ * found. Not necessarily a row that was written: when one duplicate is already
108
+ * inactive and another is live, only the live one is touched. `sysIds` is the
109
+ * complete picture.
110
+ */
111
+ sysId: string;
112
+ /**
113
+ * EVERY sys_choice row that matched this language::value; [] when missing. A field
114
+ * can hold more than one row for a value (sys_choice has no uniqueness constraint),
115
+ * and all live ones are deactivated — acting on just the first would leave the
116
+ * choice selectable while reporting success.
117
+ *
118
+ * Required, unlike its counterpart on ChoiceActionResult: this type is new in the
119
+ * remove-choices verb and has never shipped, so there is no consumer to break, and
120
+ * the formatter can read it without an existence guard.
121
+ */
122
+ sysIds: Array<string>;
123
+ /**
124
+ * deactivated — was active, now inactive=true.
125
+ * unchanged — already inactive; nothing written (idempotent).
126
+ * missing — no such value on this field.language; nothing written.
127
+ */
128
+ action: "deactivated" | "unchanged" | "missing";
129
+ }
130
+ export interface RemoveChoicesResult {
131
+ field: {
132
+ table: string;
133
+ column: string;
134
+ language: string;
135
+ dictionarySysId: string;
136
+ };
137
+ updateSet: {
138
+ sysId: string;
139
+ name: string;
140
+ };
141
+ choices: Array<ChoiceRemovalResult>;
142
+ }
73
143
  /** Outcome of a single sys_ui_* record within a layout reconcile. */
74
144
  export type LayoutAction = "created" | "updated" | "deleted" | "unchanged";
75
145
  /** Per-record result row — one ServiceNow sys_ui_* record touched (or planned). */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tenonhq/dovetail-servicenow",
3
- "version": "0.0.34",
3
+ "version": "0.0.35",
4
4
  "engines": {
5
5
  "node": ">=22"
6
6
  },