@tenonhq/dovetail-servicenow 0.0.34 → 0.0.36
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 +61 -1
- package/dist/choices.d.ts +53 -1
- package/dist/choices.js +312 -27
- package/dist/cli.js +110 -15
- package/dist/client.d.ts +9 -0
- package/dist/client.js +32 -5
- package/dist/createClientFromEnvFile.d.ts +3 -2
- package/dist/createClientFromEnvFile.js +13 -3
- package/dist/executionContext.d.ts +84 -0
- package/dist/executionContext.js +105 -0
- package/dist/formatter.d.ts +7 -1
- package/dist/formatter.js +65 -0
- package/dist/index.d.ts +5 -3
- package/dist/index.js +10 -2
- package/dist/mcp/registry.d.ts +1 -1
- package/dist/mcp/registry.js +20 -0
- package/dist/mcp/schemas.d.ts +19 -0
- package/dist/mcp/schemas.js +8 -1
- package/dist/types.d.ts +79 -2
- package/package.json +1 -1
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 {
|
|
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
|
-
|
|
79
|
-
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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 <
|
|
145
|
-
var choice =
|
|
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
|
|
148
|
-
|
|
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:
|
|
317
|
+
sysId: matchSysIds[0],
|
|
318
|
+
sysIds: matchSysIds,
|
|
153
319
|
action: "unchanged",
|
|
154
320
|
});
|
|
155
321
|
continue;
|
|
156
322
|
}
|
|
157
|
-
if (
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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:
|
|
348
|
+
sysId: matchSysIds[0],
|
|
349
|
+
sysIds: matchSysIds,
|
|
176
350
|
action: "updated",
|
|
177
351
|
});
|
|
178
352
|
continue;
|
|
179
353
|
}
|
|
180
|
-
var created
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
+
}
|