@jacebenson/jsn 4.2.4 → 4.3.1

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.
@@ -0,0 +1,178 @@
1
+ import fs from 'node:fs';
2
+ import { getStringField, formatRecordForDisplay, interactiveList, assertSafeExactMatch, confirmDelete } from '../helpers.js';
3
+ import { resolveRecord, unwrapSysId, isSysId } from '../resolve-record.js';
4
+ import { inspectDecisionTable } from '../decision-table-inspection.js';
5
+ import { DecisionTableCRUD } from '../decision-table-crud.js';
6
+ import { declareCapabilities } from '../capabilities.js';
7
+
8
+ export const decisiontablesCmd = (wrap) => {
9
+ const name = 'decisiontables';
10
+ const singular = 'decision table';
11
+ declareCapabilities(name, { mutationSubcommands: ['create', 'update', 'delete'], devAlias: false });
12
+
13
+ const readData = (argv) => {
14
+ if (argv.dataFile) return fs.readFileSync(argv.dataFile, 'utf8');
15
+ if (argv.data) return argv.data;
16
+ throw new Error('--data or --data-file is required');
17
+ };
18
+ const dataBuilder = (y) => y
19
+ .option('data', { type: 'string', describe: 'Complete decision-table JSON payload' })
20
+ .option('data-file', { type: 'string', describe: 'Read complete decision-table JSON payload from a file' });
21
+ const resolveExecutionScope = async (app) => {
22
+ const scope = app.context?.scope;
23
+ if (!scope) return 'global';
24
+ const scopeSysId = await app.sdk.resolveScope(scope);
25
+ if (!scopeSysId) throw new Error(`Scope not found: ${scope}`);
26
+ return scopeSysId;
27
+ };
28
+ const resolveDecisionTableForDelete = async (app, identifier, scopeSysId) => {
29
+ assertSafeExactMatch(identifier);
30
+ const field = isSysId(identifier) ? 'sys_id' : 'name';
31
+ const params = new URLSearchParams({ sysparm_query: `${field}=${identifier}^sys_scope=${scopeSysId}`, sysparm_limit: '2', sysparm_display_value: 'all' });
32
+ const records = await app.sdk.list('sys_decision', params);
33
+ if (records.length === 0) throw new Error(`Decision table not found in active scope: ${identifier}`);
34
+ if (records.length > 1) throw new Error(`Decision table ${field} is ambiguous in active scope: ${identifier}`);
35
+ return records[0];
36
+ };
37
+ const resolveDecisionTableForUpdate = async (app, identifier, scopeSysId) => {
38
+ assertSafeExactMatch(identifier);
39
+ const field = isSysId(identifier) ? 'sys_id' : 'name';
40
+ const scopeClause = scopeSysId === 'global' ? '' : `^sys_scope=${scopeSysId}`;
41
+ const params = new URLSearchParams({ sysparm_query: `${field}=${identifier}${scopeClause}`, sysparm_limit: '2', sysparm_display_value: 'all' });
42
+ const records = await app.sdk.list('sys_decision', params);
43
+ if (records.length === 0) throw new Error(`Decision table not found${scopeSysId === 'global' ? '' : ' in active scope'}: ${identifier}`);
44
+ if (records.length > 1) throw new Error(`Decision table ${field} is ambiguous: ${identifier}`);
45
+ const id = unwrapSysId(records[0]);
46
+ if (!isSysId(id)) throw new Error(`Decision table returned an invalid identifier: ${identifier}`);
47
+ return id;
48
+ };
49
+ const crud = (operation) => wrap(async (argv, app) => {
50
+ app.requireInstance();
51
+ const scopeSysId = await resolveExecutionScope(app);
52
+ const adapter = new DecisionTableCRUD(app.sdk, { scope: scopeSysId });
53
+ const result = operation === 'create'
54
+ ? await adapter.create(readData(argv))
55
+ : await adapter.update(await resolveDecisionTableForUpdate(app, argv.identifier, scopeSysId), readData(argv));
56
+ app.ok(result, { summary: `Decision table ${operation}d` });
57
+ });
58
+ return {
59
+ command: `${name} [subcommand]`,
60
+ aliases: ['decisiontable'],
61
+ describe: 'Manage decision tables',
62
+ builder: (yargs) => yargs
63
+ .command({
64
+ command: 'list', aliases: ['ls'], describe: 'List decision tables',
65
+ builder: (y) => y.option('query', { type: 'string', describe: 'Encoded query (e.g. nameLIKEapproval)' })
66
+ .option('limit', { alias: 'l', type: 'number', default: 50, describe: 'Max records' }),
67
+ handler: wrap(async (argv, app) => {
68
+ app.requireInstance();
69
+
70
+ const picked = await interactiveList({
71
+ app,
72
+ table: 'sys_decision',
73
+ singular,
74
+ columns: ['name', 'sys_scope'],
75
+ limit: argv.limit,
76
+ query: argv.query || '',
77
+ labelField: 'name',
78
+ message: 'Select a decision table',
79
+ formatLabel: (record) => {
80
+ const recordName = getStringField(record, 'name') || getStringField(record, 'sys_id');
81
+ const scope = getStringField(record, 'sys_scope') || '';
82
+ return (scope && scope.toLowerCase() !== 'global') ? `${recordName} [${scope}]` : recordName;
83
+ },
84
+ promptFn: app.promptFn,
85
+ });
86
+ if (picked === undefined) return;
87
+ if (picked !== null && picked !== undefined) {
88
+ const detail = await inspectDecisionTable(app, picked);
89
+ detail._context = { instance_url: app.getEffectiveInstance(), table: 'sys_decision' };
90
+ app.ok(detail, { summary: `Decision table: ${getStringField(picked, 'name') || getStringField(picked, 'sys_id')}` });
91
+ return;
92
+ }
93
+
94
+ const params = new URLSearchParams({
95
+ sysparm_query: argv.query || 'ORDERBYDESCsys_updated_on',
96
+ sysparm_limit: String(argv.limit),
97
+ sysparm_display_value: 'all',
98
+ sysparm_fields: 'sys_id,name,description,active,sys_scope,sys_updated_on',
99
+ });
100
+ const records = await app.sdk.list('sys_decision', params);
101
+ app.ok({ table: 'sys_decision', count: records.length, columns: ['name', 'active', 'sys_scope'], records: records.map((record) => formatRecordForDisplay(record, ['name', 'active', 'sys_scope'])) }, { summary: `${records.length} decision table(s)` });
102
+ }),
103
+ })
104
+ .command({
105
+ command: 'show <identifier>', aliases: ['get'], describe: 'Show a decision table matrix',
106
+ handler: wrap(async (argv, app) => {
107
+ app.requireInstance();
108
+ const table = await resolveRecord(app.sdk, { table: 'sys_decision', identifier: argv.identifier, matchField: 'name', resource: singular });
109
+ const detail = await inspectDecisionTable(app, table);
110
+ detail._context = { instance_url: app.getEffectiveInstance(), table: 'sys_decision' };
111
+ app.ok(detail, { summary: `Decision table: ${getStringField(table, 'name') || argv.identifier}` });
112
+ }),
113
+ })
114
+ .command({
115
+ command: 'create', describe: 'Create a decision table',
116
+ builder: dataBuilder,
117
+ handler: crud('create'),
118
+ })
119
+ .command({
120
+ command: 'update <identifier>', describe: 'Update a decision table and its rows',
121
+ builder: dataBuilder,
122
+ handler: crud('update'),
123
+ })
124
+ .command({
125
+ command: 'delete <identifier>', describe: 'Delete an empty decision table',
126
+ builder: (y) => y.option('force', { type: 'boolean', default: false, describe: 'Skip confirmation' }),
127
+ handler: wrap(async (argv, app) => {
128
+ app.requireInstance();
129
+ const scopeSysId = await resolveExecutionScope(app);
130
+ const table = await resolveDecisionTableForDelete(app, argv.identifier, scopeSysId);
131
+ const id = unwrapSysId(table);
132
+ if (!id || !isSysId(id)) throw new Error(`Decision table not found or returned an invalid identifier: ${argv.identifier}`);
133
+ let records;
134
+ try {
135
+ const [inputs, answers, questions, conditions] = await Promise.all([
136
+ app.sdk.list('sys_decision_input', new URLSearchParams({ sysparm_query: `model=${id}`, sysparm_limit: '1000', sysparm_fields: 'sys_id,name,element' })),
137
+ app.sdk.list('sys_decision_multi_result_element', new URLSearchParams({ sysparm_query: `decision_table=${id}`, sysparm_limit: '1000', sysparm_fields: 'sys_id,name,element' })),
138
+ app.sdk.list('sys_decision_question', new URLSearchParams({ sysparm_query: `decision_table=${id}`, sysparm_limit: '1' })),
139
+ app.sdk.list('sn_decision_table_decision_condition', new URLSearchParams({ sysparm_query: `decision_table=${id}`, sysparm_limit: '1' })),
140
+ ]);
141
+ const childIds = [...inputs, ...answers].map((row) => unwrapSysId(row)).filter(Boolean);
142
+ childIds.forEach((childId) => { if (!isSysId(childId)) throw new Error('invalid child sys_id'); });
143
+ const choiceClauses = [...inputs, ...answers].map((row) => {
144
+ const name = getStringField(row, 'name');
145
+ const element = getStringField(row, 'element');
146
+ if (!name || !element) throw new Error('generated choice relationship is incomplete');
147
+ assertSafeExactMatch(name);
148
+ assertSafeExactMatch(element);
149
+ return `name=${name}^element=${element}`;
150
+ });
151
+ const choices = choiceClauses.length ? await app.sdk.list('sys_choice', new URLSearchParams({ sysparm_query: choiceClauses.join('^OR'), sysparm_limit: '1' })) : [];
152
+ records = [inputs, answers, questions, conditions, choices];
153
+ } catch (error) {
154
+ throw new Error('Cannot prove that the decision table is empty; refusing deletion.', { cause: error });
155
+ }
156
+ if (records.some((rows) => rows.length)) throw new Error('Cannot delete a decision table with related records. Remove all inputs, choices, answer elements, conditions, and questions first.');
157
+ await confirmDelete(app, argv, `Delete decision table '${getStringField(table, 'name') || argv.identifier}'`);
158
+ const result = await new DecisionTableCRUD(app.sdk, { scope: await resolveExecutionScope(app) }).delete(id);
159
+ app.ok(result, { summary: 'Decision table deleted' });
160
+ }),
161
+ }),
162
+ handler: wrap((_argv, app) => {
163
+ app.ok({
164
+ command: name,
165
+ guidance: [
166
+ `jsn ${name} list`,
167
+ `jsn ${name} show <name_or_sys_id>`,
168
+ `jsn ${name} create --data '<json>' (or --data-file <path>)`,
169
+ `jsn ${name} update <sys_id> --data '<json>' (or --data-file <path>)`,
170
+ `jsn ${name} delete <name_or_sys_id>`,
171
+ ],
172
+ }, { summary: 'Manage decision tables' });
173
+ }),
174
+ };
175
+ };
176
+
177
+ // Kept as a named export for direct consumers and focused tests.
178
+ export { inspectDecisionTable };
@@ -1,6 +1,5 @@
1
1
  import { buildDevCmd } from './_generic.js';
2
2
  import { getStringField } from '../../helpers.js';
3
-
4
3
  // Simple table-based dev commands — singular names passed for grammar
5
4
  // Default columns and aliases match the Go version exactly
6
5
 
@@ -109,7 +108,7 @@ export const asyncrulesCmd = (wrap) => buildDevCmd('asyncrules', 'sys_script', [
109
108
  export const triggersCmd = (wrap) => buildDevCmd('triggers', 'sysevent_register', ['trigger'], ['name', 'event_name', 'active', 'sys_scope'], wrap, { singular: 'event trigger', scopeValidation: true, devAlias: false });
110
109
 
111
110
  // ── Automation: In Memory ──
112
- export const decisiontablesCmd = (wrap) => buildDevCmd('decisiontables', 'sys_ws_definition', ['decisiontable'], ['name', 'active', 'sys_scope'], wrap, { singular: 'decision table', scopeValidation: true, readOnly: true, devAlias: false });
111
+
113
112
  export const assignmentsCmd = (wrap) => buildDevCmd('assignments', 'sysrule_assignment', ['assignment'], ['name', 'active', 'sys_scope'], wrap, { singular: 'assignment rule', scopeValidation: true, readOnly: true, devAlias: false });
114
113
 
115
114
  // ── Automation: Inbound ──