@tenonhq/dovetail-servicenow 0.0.44 → 0.0.46

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
@@ -652,6 +652,73 @@ Three consequences, each reported rather than hidden:
652
652
  single request goes out. Exit codes: `0` created / skipped / dry-run, `1` bad args, `2`
653
653
  failed (the lying-row case included).
654
654
 
655
+ ### List a table's indexes
656
+
657
+ ```bash
658
+ npx dove-sn index-list --table x_cadso_automate_message_batch_recipient --json
659
+ ```
660
+
661
+ Read-only: no form session, no writes. **`v_db_index` is the index read surface** - and
662
+ the only one. `sys_index` fails an API-LEVEL ACL (HTTP 403) for every identity including
663
+ admin, and `sys_index_column` does not exist at all (HTTP 400 `Invalid table`), so there
664
+ is no two-table index model to join and nothing to cross-check against.
665
+
666
+ Each row comes back as `{ name, columns, type, rawColumns }`. `columns` is the view's
667
+ bracketed `column_names` cell (`"[phone]"`, `"[a,b]"`) **parsed** into a list - never
668
+ substring-matched, because `"[owner_id]"` contains `"owner"`. `type` is `access_method`.
669
+
670
+ **Uniqueness is not readable.** `v_db_index` has no uniqueness field, so a unique index
671
+ and an ordinary one are indistinguishable in it: `unique` is left **absent** rather than
672
+ guessed, and `uniqueness-enforced` is reported in `unverified` on every result. Only a
673
+ duplicate-insert test proves enforcement. An empty result more likely means the table name
674
+ is wrong than that the table is unindexed - every physical table has a `PRIMARY`.
675
+
676
+ ### Create an index (composite and non-unique included)
677
+
678
+ > **A DATABASE INDEX IS A PHYSICAL, PER-INSTANCE CHANGE. IT IS NOT CAPTURED IN AN UPDATE
679
+ > SET AND DOES NOT TRAVEL WITH A PROMOTION.** Re-run `index-create` against every
680
+ > environment that needs the index (dev, test, uat, staging, prod). There is deliberately
681
+ > no `--update-set` - passing one is an error, not a silent no-op.
682
+
683
+ ```bash
684
+ # Dry-run (the DEFAULT) - sends nothing and reads nothing
685
+ npx dove-sn index-create --table x_cadso_journey_instance --columns state,created_on
686
+
687
+ # Send it
688
+ npx dove-sn index-create \
689
+ --table x_cadso_journey_instance --columns state,created_on --confirm --json
690
+ ```
691
+
692
+ This is what `add-index` cannot do. `sys_dictionary.unique` - the only record-shaped lever
693
+ - is **per-column and unique-only**, so composite and plain indexes have no record path at
694
+ all. `index-create` instead replays the platform's own index-creator form
695
+ (`sys_action=create_index`, `sysparm_index_table`, `sysparm_fields`,
696
+ `sysparm_unique_index_SKIP`) over a form-login session. That contract is lifted from the
697
+ instance's shipped `index_creator_information` UI macro, not from a guess, and the POST
698
+ target is taken from the rendered page's own `<form action>`.
699
+
700
+ - **Dry-run by default.** Without `--confirm` nothing is sent *and nothing is read*;
701
+ `--dry-run` forces a plan even with `--confirm`.
702
+ - **Idempotent.** On the live path `v_db_index` is read first, and an index over *exactly*
703
+ these columns short-circuits to `already-exists` with no form session and no write.
704
+ Column **order** is part of an index's identity - `[a,b]` is not `[b,a]`.
705
+ - **`--name` is refused.** The platform's form has no name input; ServiceNow names the
706
+ index itself. Reporting a name the instance does not carry would be a lie, so the
707
+ created index's *real* name is returned in `name` instead.
708
+ - **The read-back is the proof.** After the POST the index is polled for in `v_db_index`
709
+ (default 10 checks, 3 s apart - a build on a populated table is asynchronous). If it
710
+ never appears the status is `failed`: a form processor returning a page is not evidence
711
+ an ALTER ran, and a unique index cannot build over duplicate values (EMPTY counts).
712
+ - **Uniqueness is still never claimed.** `uniqueness-enforced` stays in `unverified` on
713
+ every status.
714
+
715
+ **Requires a username+password identity that can form-log-in.** An instance on
716
+ API-key-only auth, SSO or MFA rejects the form login however valid the API key is; the
717
+ verb fails at the session with that diagnosis rather than a mystery 302, and no `.do`
718
+ replay (including `create-table`'s) can work in that state.
719
+
720
+ Exit codes: `0` created / already-exists / dry-run, `1` bad args, `2` failed.
721
+
655
722
  ### Set a field on a record
656
723
 
657
724
  Set scalar field value(s) on an **existing** data record, capture the change into
@@ -954,7 +1021,12 @@ Claude Code and agents: `create_view`, `set_list_layout`, `set_form_layout`,
954
1021
  `set_related_lists`, `add_choices_to_field`, the schema verbs `create_table` /
955
1022
  `add_column` / `add_index` (a single-column unique index via `sys_dictionary.unique`,
956
1023
  read back from the `v_db_index` view - uniqueness enforcement is always reported
957
- unverified), the record-write verbs `set_field` (update scalar fields on an
1024
+ unverified) / `index_list` (read-only: a table's database indexes from `v_db_index`,
1025
+ the only index read surface - `sys_index` is API-level-ACL 403 and `sys_index_column`
1026
+ does not exist) / `index_create` (create an index, composite and non-unique included, by
1027
+ replaying the platform index-creator form; dry-run by default, idempotent, read back from
1028
+ `v_db_index` - and **not** captured in an update set, because a database index is a
1029
+ physical per-instance change), the record-write verbs `set_field` (update scalar fields on an
958
1030
  existing record) and `create_record` (insert one record) — both update-set-captured
959
1031
  and read-back-verified — `host_assets` (deploy a built dist/), plus the Flow Designer
960
1032
  tools `flow_view` (read a flow/subflow's step graph), `action_view` (read an action
package/dist/cli.js CHANGED
@@ -1300,6 +1300,25 @@ function printHelp() {
1300
1300
  " is treated exactly like a proven collision. Success is\n" +
1301
1301
  " read back from v_db_index; that view has no uniqueness field, so\n" +
1302
1302
  " ENFORCEMENT is always reported unverified.\n" +
1303
+ " index-list List a table's DATABASE INDEXES from the v_db_index view (read-only)\n" +
1304
+ " (--table <name> [--json])\n" +
1305
+ " sys_index is API-level-ACL 403 and sys_index_column does not\n" +
1306
+ " exist, so v_db_index is the only index read surface. It has no\n" +
1307
+ " uniqueness field, so WHICH indexes are unique is always\n" +
1308
+ " reported unverified.\n" +
1309
+ " index-create Create a DATABASE INDEX (composite and non-unique included) by\n" +
1310
+ " replaying the platform index-creator form, then read it back\n" +
1311
+ " DRY-RUN BY DEFAULT — nothing is sent or read without --confirm\n" +
1312
+ " (--table <name> --columns <a[,b,...]> [--unique]\n" +
1313
+ " [--access-method <m>] [--confirm] [--dry-run]\n" +
1314
+ " [--poll-attempts <n>] [--poll-interval-ms <n>] [--debug] [--json])\n" +
1315
+ " A DATABASE INDEX IS PHYSICAL AND PER-INSTANCE: it is NOT captured\n" +
1316
+ " in an update set and does NOT travel with a promotion — re-run it\n" +
1317
+ " against every environment. There is no --update-set for that\n" +
1318
+ " reason. Idempotent: an index over exactly those columns already\n" +
1319
+ " present returns already-exists with no write. --name is REFUSED —\n" +
1320
+ " the platform's form has no name input; the real name is returned.\n" +
1321
+ " Needs a username+password identity that can form-log-in.\n" +
1303
1322
  " set-column Update an EXISTING column's SCHEMA (label/mandatory/default/read-only/max-length),\n" +
1304
1323
  " into an update set, then verify against the instance\n" +
1305
1324
  " (--table <t> --column <c> --update-set <sys_id>\n" +
@@ -1662,6 +1681,130 @@ async function runAddIndex(flags) {
1662
1681
  return 2;
1663
1682
  return 0;
1664
1683
  }
1684
+ /**
1685
+ * dove-sn index-list:
1686
+ * --table x_cadso_automate_message_batch_recipient [--json]
1687
+ *
1688
+ * Read-only. Lists the table's database indexes from the v_db_index view — the only
1689
+ * index read surface an instance exposes (sys_index is API-level-ACL 403,
1690
+ * sys_index_column does not exist).
1691
+ *
1692
+ * Exit codes: 0 read, 1 bad args.
1693
+ */
1694
+ async function runIndexList(flags) {
1695
+ if (!flags.table) {
1696
+ process.stderr.write("index-list: --table <name> is required\n");
1697
+ return 1;
1698
+ }
1699
+ var result = await (0, table_1.listIndexes)({
1700
+ client: (0, client_1.createClient)({}),
1701
+ table: flags.table,
1702
+ });
1703
+ if (flags.json === "true") {
1704
+ process.stdout.write(JSON.stringify(result, null, 2) + "\n");
1705
+ return 0;
1706
+ }
1707
+ process.stdout.write(result.table + " — " + result.indexes.length + " index(es)\n");
1708
+ for (var i = 0; i < result.indexes.length; i += 1) {
1709
+ var idx = result.indexes[i];
1710
+ process.stdout.write(" " +
1711
+ (idx.name || "(unnamed)") +
1712
+ " [" +
1713
+ idx.columns.join(", ") +
1714
+ "] " +
1715
+ (idx.type || "(no access_method)") +
1716
+ "\n");
1717
+ }
1718
+ process.stdout.write(result.note + "\nUNVERIFIED: " + result.unverified.join(", ") + "\n");
1719
+ return 0;
1720
+ }
1721
+ /**
1722
+ * dove-sn index-create:
1723
+ * --table x_cadso_core_u_smoke --columns a,b [--unique] [--access-method <m>]
1724
+ * [--confirm] [--dry-run] [--poll-attempts <n>] [--poll-interval-ms <n>]
1725
+ * [--debug] [--json]
1726
+ *
1727
+ * DRY-RUN BY DEFAULT — nothing is sent (and nothing is even READ) without --confirm;
1728
+ * --dry-run forces a plan even with it. There is deliberately NO --update-set: a
1729
+ * database index is physical and is not captured in one.
1730
+ *
1731
+ * Exit codes: 0 created / already-exists / dry-run, 1 bad args, 2 failed (which
1732
+ * includes "the form was posted but no index was read back").
1733
+ */
1734
+ async function runIndexCreate(flags) {
1735
+ var columns = splitList(flags.columns || "");
1736
+ if (!flags.table || columns.length === 0) {
1737
+ process.stderr.write("index-create: --table <name> and --columns <a[,b,...]> are required\n");
1738
+ return 1;
1739
+ }
1740
+ if (flags["update-set"]) {
1741
+ process.stderr.write("index-create: --update-set is not accepted. A database index is a PHYSICAL, " +
1742
+ "PER-INSTANCE change — it is NOT captured in an update set and does not " +
1743
+ "travel with a promotion. Run index-create against each environment.\n");
1744
+ return 1;
1745
+ }
1746
+ var params = {
1747
+ client: (0, client_1.createClient)({}),
1748
+ table: flags.table,
1749
+ columns: columns,
1750
+ unique: flags.unique === "true",
1751
+ confirm: flags.confirm === "true",
1752
+ dryRun: flags["dry-run"] === "true",
1753
+ };
1754
+ if (flags.name !== undefined)
1755
+ params.name = flags.name;
1756
+ if (flags["access-method"])
1757
+ params.accessMethod = flags["access-method"];
1758
+ if (flags["form-path"])
1759
+ params.formPath = flags["form-path"];
1760
+ if (flags.debug === "true")
1761
+ params.debug = true;
1762
+ // A non-integer poll setting would make the bounded wait unbounded (or zero).
1763
+ // Reject it here with a named message instead of letting NaN reach the loop.
1764
+ var numeric = [
1765
+ ["poll-attempts", "pollAttempts"],
1766
+ ["poll-interval-ms", "pollIntervalMs"],
1767
+ ];
1768
+ for (var n = 0; n < numeric.length; n += 1) {
1769
+ var raw = flags[numeric[n][0]];
1770
+ if (raw === undefined)
1771
+ continue;
1772
+ var value = Number(raw);
1773
+ if (!Number.isInteger(value) || value <= 0) {
1774
+ process.stderr.write("index-create: --" +
1775
+ numeric[n][0] +
1776
+ " must be a positive integer (got '" +
1777
+ raw +
1778
+ "')\n");
1779
+ return 1;
1780
+ }
1781
+ params[numeric[n][1]] = value;
1782
+ }
1783
+ var result = await (0, table_1.createIndex)(params);
1784
+ if (flags.json === "true") {
1785
+ process.stdout.write(JSON.stringify(result, null, 2) + "\n");
1786
+ }
1787
+ else {
1788
+ process.stdout.write("[" +
1789
+ result.status +
1790
+ "] " +
1791
+ result.table +
1792
+ " [" +
1793
+ result.columns.join(", ") +
1794
+ "]" +
1795
+ (result.name ? " -> " + result.name : "") +
1796
+ (result.instance ? " on " + result.instance : "") +
1797
+ (result.verified ? " — verified" : "") +
1798
+ "\n" +
1799
+ result.note +
1800
+ "\nUNVERIFIED: " +
1801
+ result.unverified.join(", ") +
1802
+ "\n");
1803
+ }
1804
+ if (result.status === "failed")
1805
+ return 2;
1806
+ return 0;
1807
+ }
1665
1808
  /** Parse a CLI boolean flag. Bare `--mandatory` means true; `--mandatory false` means
1666
1809
  * false. Anything else is rejected rather than quietly coerced to `true`. */
1667
1810
  function parseBoolFlag(name, raw, verb = "set-column") {
@@ -2544,6 +2687,12 @@ async function main() {
2544
2687
  if (parsed.command === "add-index") {
2545
2688
  return await runAddIndex(parsed.flags);
2546
2689
  }
2690
+ if (parsed.command === "index-list") {
2691
+ return await runIndexList(parsed.flags);
2692
+ }
2693
+ if (parsed.command === "index-create") {
2694
+ return await runIndexCreate(parsed.flags);
2695
+ }
2547
2696
  if (parsed.command === "set-column") {
2548
2697
  return await runSetColumn(parsed.flags, parsed.bare);
2549
2698
  }
@@ -14,6 +14,21 @@
14
14
  * ├─ sys_hub_step_ext_input (model_id → step)
15
15
  * └─ sys_hub_step_ext_output (model_id → step)
16
16
  *
17
+ * Plus two stores the action's IO depends on that are NOT FK children
18
+ * (verified live on tenonworkstudio 2026-09-30):
19
+ *
20
+ * sys_element_mapping output → step-output wiring, one row per mapped output
21
+ * (incl. __action_status__ / __dont_treat_as_error__):
22
+ * id = action sys_id, table = var__m_sys_hub_action_output_<action>,
23
+ * field = output element, value = the pill. Without these a
24
+ * clone publishes with every output unmapped (runs, returns empty).
25
+ * sys_documentation IO labels: name = var__m_sys_hub_action_{input,output}_<action>,
26
+ * element, label, language. Without these Flow Designer shows the
27
+ * element name as the label.
28
+ *
29
+ * Action IO rows are var_dictionary rows whose `name` IS their var__m table, so
30
+ * the clone retargets `name` onto the new action's var__m table too.
31
+ *
17
32
  * Publish reuses the proven snapshot path (publishActionType): the SOURCE
18
33
  * action's step graph is read from /step_instances, each step's `action` and
19
34
  * `sys_id` are remapped onto the clone (old→new sys_id map), optional StepOps
@@ -77,6 +92,12 @@ export interface CloneActionTypePlan {
77
92
  sourceScopeSysId: string;
78
93
  /** Old → new sys_id for every cloned step instance. */
79
94
  stepIdMap: Record<string, string>;
95
+ /**
96
+ * The sys_documentation (label) ops inside `ops`. They are UPSERTED after the
97
+ * graph is written (the dictionary insert may already have created the row),
98
+ * not run through executeWritePlan.
99
+ */
100
+ labelOpIds: Array<string>;
80
101
  }
81
102
  export interface CloneActionTypeStepReport {
82
103
  /** Step summary of the cloned graph before stepOps. */
@@ -99,6 +120,10 @@ export interface CloneActionTypeResult {
99
120
  verify?: VerifyStepsResult;
100
121
  steps?: CloneActionTypeStepReport;
101
122
  }
123
+ /** var_dictionary table name for a model's variables: var__m_<table>_<model sys_id>. */
124
+ export declare function varTableName(table: string, modelSysId: string): string;
125
+ /** The encoded query for an action's output → step-output mappings. */
126
+ export declare function outputMappingQuery(actionSysId: string): string;
102
127
  /** Slug for internal_name: lowercase, runs of non-alphanumerics → "_", trimmed of "_". */
103
128
  export declare function slugInternalName(name: string): string;
104
129
  /**
@@ -117,3 +142,18 @@ export declare function resolveScope(client: ServiceNowClient, ref: string): Pro
117
142
  */
118
143
  export declare function remapClonedSteps(steps: Array<StepRecord>, newParentSysId: string, stepIdMap: Record<string, string>, idMap: Record<string, string>): Array<StepRecord>;
119
144
  export declare function cloneActionType(opts: CloneActionTypeParams): Promise<CloneActionTypeResult>;
145
+ /**
146
+ * Set `name` on a cloned var_dictionary row (action IO / step ext IO) to the
147
+ * clone's var__m table — but only when it named the SOURCE model's var__m table
148
+ * exactly; anything else is left as it was.
149
+ */
150
+ export declare function retargetVarName(fields: Record<string, unknown>, table: string, oldModelSysId: string, newModelSysId: string): void;
151
+ /**
152
+ * Compare the source action's output mappings with the clone's, by `field`.
153
+ * A source field absent on the clone fails; a differing value is noted but does
154
+ * not fail (publish may legitimately normalise a pill).
155
+ */
156
+ export declare function verifyOutputMappings(sourceRows: Array<Record<string, unknown>>, cloneRows: Array<Record<string, unknown>>): {
157
+ ok: boolean;
158
+ notes: Array<string>;
159
+ };
@@ -15,6 +15,21 @@
15
15
  * ├─ sys_hub_step_ext_input (model_id → step)
16
16
  * └─ sys_hub_step_ext_output (model_id → step)
17
17
  *
18
+ * Plus two stores the action's IO depends on that are NOT FK children
19
+ * (verified live on tenonworkstudio 2026-09-30):
20
+ *
21
+ * sys_element_mapping output → step-output wiring, one row per mapped output
22
+ * (incl. __action_status__ / __dont_treat_as_error__):
23
+ * id = action sys_id, table = var__m_sys_hub_action_output_<action>,
24
+ * field = output element, value = the pill. Without these a
25
+ * clone publishes with every output unmapped (runs, returns empty).
26
+ * sys_documentation IO labels: name = var__m_sys_hub_action_{input,output}_<action>,
27
+ * element, label, language. Without these Flow Designer shows the
28
+ * element name as the label.
29
+ *
30
+ * Action IO rows are var_dictionary rows whose `name` IS their var__m table, so
31
+ * the clone retargets `name` onto the new action's var__m table too.
32
+ *
18
33
  * Publish reuses the proven snapshot path (publishActionType): the SOURCE
19
34
  * action's step graph is read from /step_instances, each step's `action` and
20
35
  * `sys_id` are remapped onto the clone (old→new sys_id map), optional StepOps
@@ -32,10 +47,14 @@
32
47
  * docs/servicenow-flow-designer-headless-authoring.md.
33
48
  */
34
49
  Object.defineProperty(exports, "__esModule", { value: true });
50
+ exports.varTableName = varTableName;
51
+ exports.outputMappingQuery = outputMappingQuery;
35
52
  exports.slugInternalName = slugInternalName;
36
53
  exports.resolveScope = resolveScope;
37
54
  exports.remapClonedSteps = remapClonedSteps;
38
55
  exports.cloneActionType = cloneActionType;
56
+ exports.retargetVarName = retargetVarName;
57
+ exports.verifyOutputMappings = verifyOutputMappings;
39
58
  const shape_1 = require("./shape");
40
59
  const writeOrder_1 = require("./writeOrder");
41
60
  const actionTypeApi_1 = require("./actionTypeApi");
@@ -47,6 +66,16 @@ var OUTPUT_TABLE = "sys_hub_action_output";
47
66
  var STEP_TABLE = "sys_hub_step_instance";
48
67
  var EXT_INPUT_TABLE = "sys_hub_step_ext_input";
49
68
  var EXT_OUTPUT_TABLE = "sys_hub_step_ext_output";
69
+ var MAPPING_TABLE = "sys_element_mapping";
70
+ var LABEL_TABLE = "sys_documentation";
71
+ /** var_dictionary table name for a model's variables: var__m_<table>_<model sys_id>. */
72
+ function varTableName(table, modelSysId) {
73
+ return "var__m_" + table + "_" + modelSysId;
74
+ }
75
+ /** The encoded query for an action's output → step-output mappings. */
76
+ function outputMappingQuery(actionSysId) {
77
+ return "id=" + actionSysId + "^tableSTARTSWITH" + "var__m_" + OUTPUT_TABLE + "_";
78
+ }
50
79
  /** Rows per child query. A full page means the read may be truncated — refuse rather than half-clone. */
51
80
  var CHILD_LIMIT = 1000;
52
81
  /**
@@ -307,6 +336,10 @@ async function cloneActionType(opts) {
307
336
  scope: scope.name,
308
337
  },
309
338
  ];
339
+ // element → plan op id, per IO table, so label/mapping ops can depend on their row.
340
+ var ioOpByElement = {};
341
+ ioOpByElement[INPUT_TABLE] = {};
342
+ ioOpByElement[OUTPUT_TABLE] = {};
310
343
  var pushModelChildren = function (table, prefix, rows) {
311
344
  for (var r = 0; r < rows.length; r++) {
312
345
  var src = flattenRow(rows[r]);
@@ -314,6 +347,11 @@ async function cloneActionType(opts) {
314
347
  idMap[String(src.sys_id || "")] = childSysId;
315
348
  var fields = cloneFields(src, scope.sysId, childSysId);
316
349
  fields.model_id = newParentSysId;
350
+ retargetVarName(fields, table, opts.sourceSysId, newParentSysId);
351
+ var element = String(src.element || "");
352
+ if (element) {
353
+ ioOpByElement[table][element] = prefix + ":" + r;
354
+ }
317
355
  ops.push({
318
356
  id: prefix + ":" + r,
319
357
  logicalName: table + ":" + String(src.element || src.name || src.sys_id),
@@ -356,6 +394,7 @@ async function cloneActionType(opts) {
356
394
  idMap[String(srcExt.sys_id || "")] = newExtSysId;
357
395
  var extFields = cloneFields(srcExt, scope.sysId, newExtSysId);
358
396
  extFields.model_id = newStepSysId;
397
+ retargetVarName(extFields, extSpecs[x].table, oldStepSysId, newStepSysId);
359
398
  ops.push({
360
399
  id: stepOpId + ":" + extSpecs[x].prefix + ":" + e,
361
400
  logicalName: extSpecs[x].table + ":" + String(srcExt.element || srcExt.name || srcExt.sys_id),
@@ -367,13 +406,78 @@ async function cloneActionType(opts) {
367
406
  }
368
407
  }
369
408
  }
409
+ // 3b. Output → step-output wiring (sys_element_mapping). Step cids survive the
410
+ // clone, so field and value (the pill) carry over unchanged.
411
+ var newOutputVarTable = varTableName(OUTPUT_TABLE, newParentSysId);
412
+ var sourceMappings = await queryChildren(client, MAPPING_TABLE, outputMappingQuery(opts.sourceSysId));
413
+ for (var mi = 0; mi < sourceMappings.length; mi++) {
414
+ var srcMap = flattenRow(sourceMappings[mi]);
415
+ var mapField = String(srcMap.field || "");
416
+ var mapFields = cloneFields(srcMap, scope.sysId, (0, shape_1.generateSysId)());
417
+ mapFields.id = newParentSysId;
418
+ mapFields.table = newOutputVarTable;
419
+ // After the parent and its outputs (the matching output first), so a mapping never precedes the row it wires.
420
+ var mapDeps = ["parent"];
421
+ if (mapField && Object.prototype.hasOwnProperty.call(ioOpByElement[OUTPUT_TABLE], mapField)) {
422
+ mapDeps.push(ioOpByElement[OUTPUT_TABLE][mapField]);
423
+ }
424
+ var outputOpIds = Object.keys(ioOpByElement[OUTPUT_TABLE]).map(function (el) { return ioOpByElement[OUTPUT_TABLE][el]; });
425
+ for (var oi = 0; oi < outputOpIds.length; oi++) {
426
+ if (mapDeps.indexOf(outputOpIds[oi]) === -1) {
427
+ mapDeps.push(outputOpIds[oi]);
428
+ }
429
+ }
430
+ ops.push({
431
+ id: "mapping:" + mi,
432
+ logicalName: MAPPING_TABLE + ":" + (mapField || String(srcMap.sys_id || mi)),
433
+ table: MAPPING_TABLE,
434
+ fields: mapFields,
435
+ dependsOn: mapDeps,
436
+ scope: scope.name,
437
+ });
438
+ }
439
+ // 3c. IO labels (sys_documentation keyed on the var__m table), retargeted.
440
+ var labelOps = [];
441
+ var labelSpecs = [
442
+ { table: INPUT_TABLE, prefix: "label:input" },
443
+ { table: OUTPUT_TABLE, prefix: "label:output" },
444
+ ];
445
+ for (var li = 0; li < labelSpecs.length; li++) {
446
+ var spec = labelSpecs[li];
447
+ var srcVarTable = varTableName(spec.table, opts.sourceSysId);
448
+ var newVarTable = varTableName(spec.table, newParentSysId);
449
+ var labelRows = await queryChildren(client, LABEL_TABLE, "name=" + srcVarTable);
450
+ for (var lr = 0; lr < labelRows.length; lr++) {
451
+ var srcLabel = flattenRow(labelRows[lr]);
452
+ var labelElement = String(srcLabel.element || "");
453
+ if (!labelElement) {
454
+ continue;
455
+ }
456
+ var labelFields = cloneFields(srcLabel, scope.sysId, (0, shape_1.generateSysId)());
457
+ labelFields.name = newVarTable;
458
+ var labelDeps = ["parent"];
459
+ if (Object.prototype.hasOwnProperty.call(ioOpByElement[spec.table], labelElement)) {
460
+ labelDeps.push(ioOpByElement[spec.table][labelElement]);
461
+ }
462
+ labelOps.push({
463
+ id: spec.prefix + ":" + lr,
464
+ logicalName: LABEL_TABLE + ":" + newVarTable + "." + labelElement,
465
+ table: LABEL_TABLE,
466
+ fields: labelFields,
467
+ dependsOn: labelDeps,
468
+ scope: scope.name,
469
+ });
470
+ }
471
+ }
472
+ var allOps = ops.concat(labelOps);
370
473
  var plan = {
371
- ops: ops,
372
- counts: countByTable(ops),
373
- total: ops.length,
474
+ ops: allOps,
475
+ counts: countByTable(allOps),
476
+ total: allOps.length,
374
477
  scope: scope,
375
478
  sourceScopeSysId: sourceScopeSysId,
376
479
  stepIdMap: stepIdMap,
480
+ labelOpIds: labelOps.map(function (o) { return o.id; }),
377
481
  };
378
482
  // 4. The publishable step graph: the SOURCE's /step_instances, remapped.
379
483
  var sourceSteps = await (0, actionTypeApi_1.fetchActionSteps)(client, opts.sourceSysId, sourceScopeSysId);
@@ -412,6 +516,11 @@ async function cloneActionType(opts) {
412
516
  // 5. Write the graph, pinned to the update set.
413
517
  var updateSetSysId = opts.updateSetSysId;
414
518
  var written = await (0, writeOrder_1.executeWritePlan)(client, ops, updateSetSysId);
519
+ // Labels last: the IO dictionary inserts above may already have created a
520
+ // sys_documentation row for the new var__m table, so upsert rather than duplicate.
521
+ for (var lw = 0; lw < labelOps.length; lw++) {
522
+ written.push(await upsertLabel(client, labelOps[lw], updateSetSysId));
523
+ }
415
524
  var result = {
416
525
  sysId: newParentSysId,
417
526
  internalName: internalName,
@@ -440,16 +549,129 @@ async function cloneActionType(opts) {
440
549
  }
441
550
  // 7. Verify — a 201 means "compiled", not "landed as sent". Read the clone's steps back.
442
551
  var fresh = await (0, actionTypeApi_1.fetchActionSteps)(client, newParentSysId, scope.sysId);
552
+ var verify;
443
553
  if (fresh.length === 0) {
444
- result.verify = { ok: false, notes: ["read-back returned no steps — could not verify the publish"] };
554
+ verify = { ok: false, notes: ["read-back returned no steps — could not verify the publish"] };
445
555
  }
446
556
  else {
447
557
  var expected = stepReport.after;
448
- result.verify = (0, stepOps_1.verifySteps)(expected, (0, stepOps_1.summarizeSteps)(fresh), allCids(finalSteps));
558
+ verify = (0, stepOps_1.verifySteps)(expected, (0, stepOps_1.summarizeSteps)(fresh), allCids(finalSteps));
449
559
  if (fresh.length !== finalSteps.length) {
450
- result.verify.ok = false;
451
- result.verify.notes.push("step count on the instance is " + fresh.length + ", expected " + finalSteps.length);
560
+ verify.ok = false;
561
+ verify.notes.push("step count on the instance is " + fresh.length + ", expected " + finalSteps.length);
452
562
  }
453
563
  }
564
+ // Output mappings: every source mapping must exist on the clone, or its outputs come back empty.
565
+ var cloneMappings = await queryChildren(client, MAPPING_TABLE, outputMappingQuery(newParentSysId));
566
+ var mappingCheck = verifyOutputMappings(sourceMappings, cloneMappings);
567
+ if (!mappingCheck.ok) {
568
+ verify.ok = false;
569
+ }
570
+ for (var vn = 0; vn < mappingCheck.notes.length; vn++) {
571
+ verify.notes.push(mappingCheck.notes[vn]);
572
+ }
573
+ result.verify = verify;
454
574
  return result;
455
575
  }
576
+ /**
577
+ * Set `name` on a cloned var_dictionary row (action IO / step ext IO) to the
578
+ * clone's var__m table — but only when it named the SOURCE model's var__m table
579
+ * exactly; anything else is left as it was.
580
+ */
581
+ function retargetVarName(fields, table, oldModelSysId, newModelSysId) {
582
+ if (!Object.prototype.hasOwnProperty.call(fields, "name")) {
583
+ return;
584
+ }
585
+ if (String(fields.name) === varTableName(table, oldModelSysId)) {
586
+ fields.name = varTableName(table, newModelSysId);
587
+ }
588
+ }
589
+ /**
590
+ * Compare the source action's output mappings with the clone's, by `field`.
591
+ * A source field absent on the clone fails; a differing value is noted but does
592
+ * not fail (publish may legitimately normalise a pill).
593
+ */
594
+ function verifyOutputMappings(sourceRows, cloneRows) {
595
+ var cloneByField = {};
596
+ for (var c = 0; c < cloneRows.length; c++) {
597
+ var cr = flattenRow(cloneRows[c]);
598
+ var cf = String(cr.field || "");
599
+ if (cf) {
600
+ cloneByField[cf] = String(cr.value === undefined || cr.value === null ? "" : cr.value);
601
+ }
602
+ }
603
+ var missing = [];
604
+ var notes = [];
605
+ for (var s = 0; s < sourceRows.length; s++) {
606
+ var sr = flattenRow(sourceRows[s]);
607
+ var sf = String(sr.field || "");
608
+ if (!sf) {
609
+ continue;
610
+ }
611
+ if (!Object.prototype.hasOwnProperty.call(cloneByField, sf)) {
612
+ missing.push(sf);
613
+ continue;
614
+ }
615
+ var sv = String(sr.value === undefined || sr.value === null ? "" : sr.value);
616
+ if (cloneByField[sf] !== sv) {
617
+ notes.push("output mapping '" + sf + "' differs on the clone: '" + cloneByField[sf] + "' (source '" + sv + "')");
618
+ }
619
+ }
620
+ if (missing.length > 0) {
621
+ notes.unshift(missing.length + " output mapping(s) missing on the clone (" + MAPPING_TABLE + "): " + missing.join(", "));
622
+ }
623
+ else if (sourceRows.length > 0) {
624
+ notes.unshift("all " + sourceRows.length + " output mapping(s) present on the clone");
625
+ }
626
+ return { ok: missing.length === 0, notes: notes };
627
+ }
628
+ /**
629
+ * Write one cloned sys_documentation row: update the existing (name, element,
630
+ * language) row on the target if the platform already made one, else create it.
631
+ */
632
+ async function upsertLabel(client, op, updateSetSysId) {
633
+ var f = op.fields;
634
+ var keyParts = [String(f.name), String(f.element || ""), String(f.language || "")];
635
+ for (var k = 0; k < keyParts.length; k++) {
636
+ if (/[\^\r\n]/.test(keyParts[k])) {
637
+ throw new Error("cloneActionType: label key " + JSON.stringify(keyParts[k]) + " contains '^' or a line break.");
638
+ }
639
+ }
640
+ var query = "name=" + keyParts[0] + "^element=" + keyParts[1];
641
+ if (keyParts[2] !== "") {
642
+ query += "^language=" + keyParts[2];
643
+ }
644
+ try {
645
+ var hits = await client.table.query(LABEL_TABLE, query, { limit: 1, fields: ["sys_id"] });
646
+ var existingSysId = hits.length > 0 ? String(flattenRow(hits[0]).sys_id || "") : "";
647
+ if (RX_SYS_ID.test(existingSysId)) {
648
+ var update = Object.assign({}, f);
649
+ delete update.sys_id;
650
+ await client.claude.pushWithUpdateSet({
651
+ update_set_sys_id: updateSetSysId,
652
+ table: LABEL_TABLE,
653
+ record_sys_id: existingSysId,
654
+ fields: update,
655
+ });
656
+ return { id: op.id, logicalName: op.logicalName, table: op.table, sysId: existingSysId, action: "updated" };
657
+ }
658
+ var created = await client.claude.createRecord({
659
+ table: op.table,
660
+ fields: f,
661
+ scope: op.scope,
662
+ update_set_sys_id: updateSetSysId,
663
+ sys_id: String(f.sys_id),
664
+ });
665
+ return {
666
+ id: op.id,
667
+ logicalName: op.logicalName,
668
+ table: op.table,
669
+ sysId: created.sys_id ? String(created.sys_id) : String(f.sys_id),
670
+ action: "created",
671
+ };
672
+ }
673
+ catch (err) {
674
+ var msg = err instanceof Error ? err.message : String(err);
675
+ throw new Error("cloneActionType: label write failed at op '" + op.id + "' (" + op.table + "): " + msg);
676
+ }
677
+ }
@@ -36,8 +36,12 @@ export interface WriteOpResult {
36
36
  logicalName: string;
37
37
  table: string;
38
38
  sysId: string;
39
- /** Currently always "created" — claude.createRecord doesn't distinguish. */
40
- action: "created";
39
+ /**
40
+ * executeWritePlan always reports "created" — claude.createRecord doesn't
41
+ * distinguish. "updated" is reported by callers that upsert (cloneActionType's
42
+ * label rows, which the platform may already have created on the dictionary insert).
43
+ */
44
+ action: "created" | "updated";
41
45
  }
42
46
  export declare class WriteOrderError extends Error {
43
47
  readonly cycleIds?: Array<string> | undefined;
package/dist/index.d.ts CHANGED
@@ -21,8 +21,8 @@ export { sincPlugin } from "./plugin";
21
21
  export { listTemplates, verifyArtifact, cloneSubflow, cloneActionType, resolveScope, slugInternalName, fetchActionSteps, findStepInput, triggerPublication, publishActionType, editActionType, defineActionType, planActionDefinition, validateDefineSpec, applyStepOps, verifySteps, summarizeSteps, formatStepPill, readFlow, readActionType, publishFlow, copyFlow, createFlow, buildPublishModel, editFlow, testFlow, DEFAULT_RUN_FLOW_PATH, generateSysId, topoSort, executeWritePlan, WriteOrderError, } from "./flowDesigner";
22
22
  export type { TemplateRef, ListTemplatesParams, FlowKind, VerifyExpect, VerifyFound, VerifyFailure, VerifyReport, VerifyArtifactParams, CloneSubflowParams, CloneSubflowResult, CloneActionTypeParams, CloneActionTypeResult, CloneActionTypePlan, CloneActionTypeStepReport, TriggerPublicationParams, TriggerPublicationResult, PublishActionTypeParams, PublishActionTypeResult, EditActionTypeParams, EditActionTypeResult, EditActionTypeOps, DefineActionSpec, DefineActionInputSpec, DefineActionOutputSpec, DefineStepSpec, DefineStepVarSpec, DefineStepOutputSpec, DefineStepValue, DefineActionTypeParams, DefineActionTypeResult, DefineActionDiff, DefineActionPlan, PlanActionDefinitionParams, ActionView, StepView, StepOps, StepRecord, StepSummary, StepIoSummary, PatchStepScriptOp, SetStepInputOp, 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";
23
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";
24
- export { createTable, projectTableGraph, buildColumnXml, normalizeColumns, resolveType, applyTableSaveOverlay, defaultAccessFlags, TYPE_MAP, DEFAULT_SUPER_CLASS, DEFAULT_SAVE_ACTION, addColumn, deriveElement, addIndex, parseIndexColumns, indexMatchesColumns, setColumn, resolveAttributes, toStoredValue, setTable, resolveTableAttributes, } from "./table";
25
- export type { CreateTableParams, CreateTableResult, AddColumnParams, AddColumnResult, AddIndexParams, AddIndexResult, AddIndexVerification, SetColumnParams, SetColumnResult, ColumnAttributes, AttributeChange, SetTableParams, SetTableResult, TableAttributes, TableGraph, NormalizedColumn, ColumnSpec, AccessFlags, OverlaySpec, } from "./table";
24
+ export { createTable, projectTableGraph, buildColumnXml, normalizeColumns, resolveType, applyTableSaveOverlay, defaultAccessFlags, TYPE_MAP, DEFAULT_SUPER_CLASS, DEFAULT_SAVE_ACTION, addColumn, deriveElement, addIndex, parseIndexColumns, indexMatchesColumns, listIndexes, createIndex, validateCreateIndex, DEFAULT_INDEX_FORM_PATH, NOT_IN_UPDATE_SET, setColumn, resolveAttributes, toStoredValue, setTable, resolveTableAttributes, } from "./table";
25
+ export type { CreateTableParams, CreateTableResult, AddColumnParams, AddColumnResult, AddIndexParams, AddIndexResult, AddIndexVerification, ListIndexesParams, ListIndexesResult, TableIndex, CreateIndexParams, CreateIndexResult, SetColumnParams, SetColumnResult, ColumnAttributes, AttributeChange, SetTableParams, SetTableResult, TableAttributes, TableGraph, NormalizedColumn, ColumnSpec, AccessFlags, OverlaySpec, } from "./table";
26
26
  export { setField } from "./setField";
27
27
  export { createRecord } from "./createRecord";
28
28
  export type { RecordWriteResult, SetFieldParams, SetFieldResult, } from "./setField";