@tenonhq/dovetail-servicenow 0.0.36 → 0.0.37

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
@@ -367,6 +367,56 @@ adds diagnostics (app-switch status, resolved column key, assigned sys_id) to th
367
367
  result note. Ground truth (the HAR dissection) lives in the CTO repo's create-table
368
368
  docs.
369
369
 
370
+ ### Add a unique index
371
+
372
+ Create a **single-column UNIQUE index** on an existing table, then read it back.
373
+
374
+ ```bash
375
+ # Dry-run (the DEFAULT) - prints the plan, writes nothing and reads nothing
376
+ npx dove-sn add-index \
377
+ --table x_cadso_journey_instance --columns occurrence_key --unique \
378
+ --update-set <sys_id> --json
379
+
380
+ # Send it
381
+ npx dove-sn add-index \
382
+ --table x_cadso_journey_instance --columns occurrence_key --unique \
383
+ --update-set <sys_id> --confirm
384
+ ```
385
+
386
+ The **only** headless lever for an index is `sys_dictionary.unique`. `sys_index` fails an
387
+ API-LEVEL ACL (HTTP 403) for every identity - and an ACL that refuses `GET` refuses `POST` -
388
+ while `sys_index_column` does not exist at all (HTTP 400 `Invalid table`). So `add-index`
389
+ patches the column's dictionary row through the update-set-aware write path and lets the
390
+ platform build the physical index off that flag.
391
+
392
+ Three consequences, each reported rather than hidden:
393
+
394
+ - **One column, unique only.** `unique` is a per-COLUMN flag, so a composite index has no
395
+ dictionary lever. A multi-column request is **refused**, never narrowed to its first
396
+ column - building a different index than the one asked for is the worst available
397
+ outcome. `--unique` is required for the same reason. Composite and plain indexes stay
398
+ platform-UI work.
399
+ - **Duplicates abort the run BEFORE it writes.** A unique index cannot build over repeated
400
+ values, and ServiceNow fails that ALTER *silently* - leaving a dictionary row claiming
401
+ `unique=true` with no index behind it (which is exactly what
402
+ `x_cadso_core_metric_point.idempotency_key` looks like today). **EMPTY counts as a
403
+ value**: a freshly added column that is empty on every existing row is one collision per
404
+ row. Backfill first, index second. The scan is paged and capped, and a scan that hits
405
+ that cap **aborts the same way** - an UNPROVEN scan is treated exactly like a proven
406
+ collision, because writing on a column that was only read part-way is how this verb
407
+ would manufacture that trap on a table too big for anyone to have checked.
408
+ - **Success is read back; uniqueness never is.** `status` is `created` only when a matching
409
+ row was read back from the `v_db_index` view; a flag with no index is `failed`.
410
+ `verified.indexPresent` is `null` - UNKNOWN, not `false` - when the view could not be
411
+ read, because a blind instrument is not evidence of absence. And `v_db_index` carries no
412
+ uniqueness field (every row reads `btree`, unique or not), so **`uniqueness-enforced` is
413
+ listed in `unverified` on every status, success included**: only a duplicate-insert test
414
+ proves enforcement.
415
+
416
+ `--update-set` is required on the live path and is checked before a client is built or a
417
+ single request goes out. Exit codes: `0` created / skipped / dry-run, `1` bad args, `2`
418
+ failed (the lying-row case included).
419
+
370
420
  ### Set a field on a record
371
421
 
372
422
  Set scalar field value(s) on an **existing** data record, capture the change into
@@ -576,7 +626,9 @@ console.log(formatLayoutResult("form layout", result));
576
626
  `dove-sn mcp` runs a self-contained MCP stdio server exposing the tools to
577
627
  Claude Code and agents: `create_view`, `set_list_layout`, `set_form_layout`,
578
628
  `set_related_lists`, `add_choices_to_field`, the schema verbs `create_table` /
579
- `add_column`, the record-write verbs `set_field` (update scalar fields on an
629
+ `add_column` / `add_index` (a single-column unique index via `sys_dictionary.unique`,
630
+ read back from the `v_db_index` view - uniqueness enforcement is always reported
631
+ unverified), the record-write verbs `set_field` (update scalar fields on an
580
632
  existing record) and `create_record` (insert one record) — both update-set-captured
581
633
  and read-back-verified — `host_assets` (deploy a built dist/), plus the Flow Designer
582
634
  tools `flow_view` (read a flow/subflow's step graph), `action_view` (read an action
package/dist/cli.js CHANGED
@@ -977,6 +977,20 @@ function printHelp() {
977
977
  " [--name <element>] [--max-length <n>] [--reference <table>]\n" +
978
978
  " [--mandatory] [--default <v>] [--scope <s>] [--dry-run] [--json])\n" +
979
979
  " --update-set is REQUIRED on the live path (not for --dry-run).\n" +
980
+ " add-index Create a single-column UNIQUE index (sys_dictionary.unique), then verify\n" +
981
+ " DRY-RUN BY DEFAULT — nothing is written without --confirm\n" +
982
+ " (--table <name|sys_id> --columns <column> --unique --update-set <sys_id>\n" +
983
+ " [--confirm] [--scope <s>] [--dry-run] [--debug] [--json])\n" +
984
+ " ONE column only: unique is a per-COLUMN dictionary flag, so a\n" +
985
+ " composite index is REFUSED, not narrowed — that stays UI work,\n" +
986
+ " as does a plain (non-unique) index. The run ABORTS before writing\n" +
987
+ " when the column holds duplicate values (EMPTY counts): a unique\n" +
988
+ " index cannot build over them and the platform fails that ALTER\n" +
989
+ " SILENTLY, leaving unique=true with no index behind it. It aborts\n" +
990
+ " the same way when that scan hits its row cap — an UNPROVEN scan\n" +
991
+ " is treated exactly like a proven collision. Success is\n" +
992
+ " read back from v_db_index; that view has no uniqueness field, so\n" +
993
+ " ENFORCEMENT is always reported unverified.\n" +
980
994
  " set-column Update an EXISTING column's SCHEMA (label/mandatory/default/read-only/max-length),\n" +
981
995
  " into an update set, then verify against the instance\n" +
982
996
  " (--table <t> --column <c> --update-set <sys_id>\n" +
@@ -1222,6 +1236,82 @@ async function runAddColumn(flags) {
1222
1236
  return 2;
1223
1237
  return 0;
1224
1238
  }
1239
+ /**
1240
+ * dove-sn add-index:
1241
+ * --table x_cadso_journey_instance --columns occurrence_key --unique
1242
+ * --update-set <sys_id> [--confirm] [--scope x_cadso_journey] [--debug] [--json]
1243
+ *
1244
+ * DRY-RUN BY DEFAULT — nothing is written without --confirm (--dry-run forces a
1245
+ * dry-run even with it). --update-set is required on the live path and is checked
1246
+ * here, before a client is built or a single request goes out.
1247
+ *
1248
+ * Exit codes: 0 created / skipped / dry-run, 1 bad args, 2 failed (which includes
1249
+ * "the dictionary flag is set but no index was read back" — the lying-row case).
1250
+ */
1251
+ async function runAddIndex(flags) {
1252
+ var table = flags.table;
1253
+ var columns = (flags.columns || "")
1254
+ .split(",")
1255
+ .map(function (c) {
1256
+ return c.trim();
1257
+ })
1258
+ .filter(function (c) {
1259
+ return c.length > 0;
1260
+ });
1261
+ if (!table || columns.length === 0) {
1262
+ process.stderr.write("add-index: --table and --columns <column> are required " +
1263
+ "(--unique too, and --update-set unless this is a dry-run)\n");
1264
+ return 1;
1265
+ }
1266
+ // The only headless lever is sys_dictionary.unique. Refuse a non-unique request by
1267
+ // name instead of building something else and calling it done.
1268
+ if (flags.unique !== "true") {
1269
+ process.stderr.write("add-index: --unique is required — the only headless lever is " +
1270
+ "sys_dictionary.unique, which has no equivalent for a plain (non-unique) " +
1271
+ "index. Create that one in the platform UI.\n");
1272
+ return 1;
1273
+ }
1274
+ // DRY-RUN BY DEFAULT: --confirm is what sends; --dry-run forces a plan even with it.
1275
+ var dryRun = flags["dry-run"] === "true" || flags.confirm !== "true";
1276
+ if (!dryRun && !flags["update-set"]) {
1277
+ process.stderr.write("add-index: --update-set is required on the live path (only a dry-run works without one)\n");
1278
+ return 1;
1279
+ }
1280
+ var params = {
1281
+ client: (0, client_1.createClient)({}),
1282
+ table: table,
1283
+ columns: columns,
1284
+ unique: true,
1285
+ dryRun: dryRun,
1286
+ };
1287
+ if (flags.scope)
1288
+ params.scope = flags.scope;
1289
+ if (flags["update-set"])
1290
+ params.updateSetSysId = flags["update-set"];
1291
+ if (flags.debug === "true")
1292
+ params.debug = true;
1293
+ var result = await (0, table_1.addIndex)(params);
1294
+ if (flags.json === "true") {
1295
+ process.stdout.write(JSON.stringify(result, null, 2) + "\n");
1296
+ }
1297
+ else {
1298
+ process.stdout.write("[" +
1299
+ result.status +
1300
+ "] " +
1301
+ result.table +
1302
+ "." +
1303
+ result.columns.join(",") +
1304
+ (result.indexName ? " -> " + result.indexName : "") +
1305
+ "\n" +
1306
+ result.note +
1307
+ "\nUNVERIFIED: " +
1308
+ result.unverified.join(", ") +
1309
+ "\n");
1310
+ }
1311
+ if (result.status === "failed")
1312
+ return 2;
1313
+ return 0;
1314
+ }
1225
1315
  /** Parse a CLI boolean flag. Bare `--mandatory` means true; `--mandatory false` means
1226
1316
  * false. Anything else is rejected rather than quietly coerced to `true`. */
1227
1317
  function parseBoolFlag(name, raw, verb = "set-column") {
@@ -1851,6 +1941,9 @@ async function main() {
1851
1941
  if (parsed.command === "add-column") {
1852
1942
  return await runAddColumn(parsed.flags);
1853
1943
  }
1944
+ if (parsed.command === "add-index") {
1945
+ return await runAddIndex(parsed.flags);
1946
+ }
1854
1947
  if (parsed.command === "set-column") {
1855
1948
  return await runSetColumn(parsed.flags, parsed.bare);
1856
1949
  }
package/dist/index.d.ts CHANGED
@@ -21,8 +21,8 @@ export { sincPlugin } from "./plugin";
21
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";
22
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";
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, setColumn, resolveAttributes, toStoredValue, setTable, resolveTableAttributes, } from "./table";
25
- export type { CreateTableParams, CreateTableResult, AddColumnParams, AddColumnResult, 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, 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";
26
26
  export { setField } from "./setField";
27
27
  export { createRecord } from "./createRecord";
28
28
  export type { RecordWriteResult, SetFieldParams, SetFieldResult, } from "./setField";
package/dist/index.js CHANGED
@@ -7,7 +7,7 @@
7
7
  */
8
8
  Object.defineProperty(exports, "__esModule", { value: true });
9
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;
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.indexMatchesColumns = exports.parseIndexColumns = exports.addIndex = 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
13
  var executionContext_1 = require("./executionContext");
@@ -80,6 +80,9 @@ Object.defineProperty(exports, "DEFAULT_SUPER_CLASS", { enumerable: true, get: f
80
80
  Object.defineProperty(exports, "DEFAULT_SAVE_ACTION", { enumerable: true, get: function () { return table_1.DEFAULT_SAVE_ACTION; } });
81
81
  Object.defineProperty(exports, "addColumn", { enumerable: true, get: function () { return table_1.addColumn; } });
82
82
  Object.defineProperty(exports, "deriveElement", { enumerable: true, get: function () { return table_1.deriveElement; } });
83
+ Object.defineProperty(exports, "addIndex", { enumerable: true, get: function () { return table_1.addIndex; } });
84
+ Object.defineProperty(exports, "parseIndexColumns", { enumerable: true, get: function () { return table_1.parseIndexColumns; } });
85
+ Object.defineProperty(exports, "indexMatchesColumns", { enumerable: true, get: function () { return table_1.indexMatchesColumns; } });
83
86
  Object.defineProperty(exports, "setColumn", { enumerable: true, get: function () { return table_1.setColumn; } });
84
87
  Object.defineProperty(exports, "resolveAttributes", { enumerable: true, get: function () { return table_1.resolveAttributes; } });
85
88
  Object.defineProperty(exports, "toStoredValue", { enumerable: true, get: function () { return table_1.toStoredValue; } });
@@ -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", "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"];
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", "add_index", "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({}). */
@@ -50,6 +50,7 @@ exports.TOOL_NAMES = [
50
50
  "flow_edit",
51
51
  "create_table",
52
52
  "add_column",
53
+ "add_index",
53
54
  "set_column",
54
55
  "set_table",
55
56
  "set_field",
@@ -373,6 +374,65 @@ function buildDescriptors(deps = {}) {
373
374
  });
374
375
  },
375
376
  },
377
+ {
378
+ name: "add_index",
379
+ annotations: dovetail_mcp_kit_1.WRITE_OVERWRITE,
380
+ description: "Create a single-column UNIQUE index on an EXISTING ServiceNow table, headless. The " +
381
+ "ONLY headless lever is sys_dictionary.unique — sys_index fails an API-LEVEL ACL (403) " +
382
+ "for every identity and sys_index_column does not exist — so this patches the column's " +
383
+ "dictionary row through the update-set-aware write path and lets the platform build the " +
384
+ "physical index off that flag, then READS IT BACK from the v_db_index view. columns is a " +
385
+ "list but exactly one entry is supported: unique is a PER-COLUMN flag, so a composite " +
386
+ "request is REFUSED rather than silently narrowed to a different index than the one asked " +
387
+ "for, and unique:false is refused too (there is no dictionary lever for a plain index) — " +
388
+ "both stay platform-UI work. Before writing, the column's values are scanned and the run " +
389
+ "ABORTS on duplicates, EMPTY included: a unique index cannot build over them, and the " +
390
+ "platform fails that ALTER SILENTLY, leaving a dictionary row claiming unique=true with no " +
391
+ "index behind it (the x_cadso_core_metric_point.idempotency_key trap). That scan is paged " +
392
+ "and capped, and a scan that hits the cap ABORTS TOO — an UNPROVEN scan is treated exactly " +
393
+ "like a proven collision, because writing on a partly-read column is how this verb would " +
394
+ "manufacture that trap on a table too big to have been checked. status is 'created' " +
395
+ "only when a matching v_db_index row was read back; a flag with no index is 'failed', and " +
396
+ "verified.indexPresent is null (UNKNOWN) when the view could not be read — never false, " +
397
+ "because a blind instrument is not evidence of absence. 'uniqueness-enforced' is ALWAYS " +
398
+ "reported in unverified: v_db_index carries no uniqueness field, so enforcement is provable " +
399
+ "only by a duplicate-insert test. updateSetSysId is required on the live path; dryRun:true " +
400
+ "returns the plan with no reads and no writes.",
401
+ shape: schemas_1.addIndexSchema.shape,
402
+ handler: async function (args) {
403
+ var p = schemas_1.addIndexSchema.parse(args);
404
+ // The schema leaves updateSetSysId optional (dry-run doesn't need one), so
405
+ // enforce the live-path requirement HERE — a tool-level error before any
406
+ // work beats a failure surfacing from deep inside addIndex. Same pattern as
407
+ // add_column.
408
+ if (p.dryRun !== true &&
409
+ (!p.updateSetSysId || !p.updateSetSysId.trim())) {
410
+ throw new Error("add_index: updateSetSysId is required on the live path so the " +
411
+ "sys_dictionary change is captured in a known update set — " +
412
+ "set dryRun:true to plan without one.");
413
+ }
414
+ // `unique` is boolean at the boundary (unvalidated JSON arrives here), but only
415
+ // true is buildable — refuse it by name rather than let a caller believe a plain
416
+ // index was created.
417
+ var unique = p.unique;
418
+ if (unique !== true) {
419
+ throw new Error("add_index: only a unique index can be created headlessly — the sole " +
420
+ "lever is sys_dictionary.unique, which has no equivalent for a plain " +
421
+ "(non-unique) index. Pass unique:true, or create that index in the " +
422
+ "platform UI.");
423
+ }
424
+ return (0, table_1.addIndex)({
425
+ client: client(),
426
+ table: p.table,
427
+ columns: p.columns,
428
+ unique: unique,
429
+ scope: p.scope,
430
+ updateSetSysId: p.updateSetSysId,
431
+ dryRun: p.dryRun,
432
+ debug: p.debug,
433
+ });
434
+ },
435
+ },
376
436
  {
377
437
  name: "set_column",
378
438
  annotations: dovetail_mcp_kit_1.WRITE_OVERWRITE,
@@ -849,6 +849,31 @@ export declare var addColumnSchema: z.ZodObject<{
849
849
  scope?: string | undefined;
850
850
  updateSetSysId?: string | undefined;
851
851
  }>;
852
+ export declare var addIndexSchema: z.ZodObject<{
853
+ table: z.ZodString;
854
+ columns: z.ZodArray<z.ZodString, "many">;
855
+ unique: z.ZodBoolean;
856
+ scope: z.ZodOptional<z.ZodString>;
857
+ updateSetSysId: z.ZodOptional<z.ZodString>;
858
+ dryRun: z.ZodOptional<z.ZodBoolean>;
859
+ debug: z.ZodOptional<z.ZodBoolean>;
860
+ }, "strip", z.ZodTypeAny, {
861
+ columns: string[];
862
+ table: string;
863
+ unique: boolean;
864
+ dryRun?: boolean | undefined;
865
+ debug?: boolean | undefined;
866
+ scope?: string | undefined;
867
+ updateSetSysId?: string | undefined;
868
+ }, {
869
+ columns: string[];
870
+ table: string;
871
+ unique: boolean;
872
+ dryRun?: boolean | undefined;
873
+ debug?: boolean | undefined;
874
+ scope?: string | undefined;
875
+ updateSetSysId?: string | undefined;
876
+ }>;
852
877
  export declare var columnAttributesSchema: z.ZodObject<{
853
878
  label: z.ZodOptional<z.ZodString>;
854
879
  mandatory: 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.removeChoicesFromFieldSchema = 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.addIndexSchema = 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),
@@ -221,6 +221,23 @@ exports.addColumnSchema = zod_1.z.object({
221
221
  dryRun: zod_1.z.boolean().optional(),
222
222
  debug: zod_1.z.boolean().optional(),
223
223
  });
224
+ // add-index keeps a column LIST because an index is conceptually multi-column, but the
225
+ // only headless lever (sys_dictionary.unique) is per-COLUMN — so addIndex REFUSES a list
226
+ // longer than one rather than silently building a different index than the one asked for.
227
+ // `unique` is a plain boolean here for the same reason `internalType` is accepted by
228
+ // set-column: a caller who asks for a non-unique index earns the explanation of why it is
229
+ // impossible instead of a schema error that reads like a typo. updateSetSysId is optional
230
+ // because dryRun needs none; the live-path requirement is enforced at the tool boundary
231
+ // (registry.ts), matching add_column.
232
+ exports.addIndexSchema = zod_1.z.object({
233
+ table: zod_1.z.string().min(1),
234
+ columns: zod_1.z.array(zod_1.z.string().min(1)).min(1),
235
+ unique: zod_1.z.boolean(),
236
+ scope: zod_1.z.string().optional(),
237
+ updateSetSysId: zod_1.z.string().min(1).optional(),
238
+ dryRun: zod_1.z.boolean().optional(),
239
+ debug: zod_1.z.boolean().optional(),
240
+ });
224
241
  // set-column takes a CLOSED attribute set, not an open field map: an unbounded write to
225
242
  // sys_dictionary lets a caller silently corrupt the schema. internalType and element are
226
243
  // listed but are NOT settable — ServiceNow honours neither on an existing column, and
@@ -0,0 +1,130 @@
1
+ /**
2
+ * addIndex — create a single-column UNIQUE index on an EXISTING ServiceNow table,
3
+ * headless, and then read the result back.
4
+ *
5
+ * THE ONLY HEADLESS LEVER IS `sys_dictionary.unique`. There is no writable index
6
+ * table: `sys_index` fails an API-LEVEL ACL (HTTP 403) for every identity — an ACL
7
+ * that refuses GET refuses POST — and `sys_index_column` does not exist (HTTP 400
8
+ * "Invalid table"). So this verb patches the column's dictionary row through the
9
+ * scope-aware `pushWithUpdateSet` op (update set + scope switched server-side,
10
+ * exactly as `addColumn` does for max_length) and lets the platform build the
11
+ * physical index off that flag.
12
+ *
13
+ * Consequences, each of which the contract encodes rather than hides:
14
+ *
15
+ * 1. PER-COLUMN ONLY. `unique` is a column flag, so a composite index has no
16
+ * dictionary lever at all. A multi-column request is REFUSED, never narrowed to
17
+ * its first column — quietly building a different index than the one asked for is
18
+ * the worst available outcome. Composite / non-unique indexes stay UI work.
19
+ *
20
+ * 2. THE FLAG IS NOT SELF-VERIFYING. `x_cadso_core_metric_point.idempotency_key`
21
+ * reads `unique=true` in the dictionary with NO index on the table — a lying row.
22
+ * So the write is never the proof: after the patch the dictionary row is re-read
23
+ * BY sys_id and the index is looked for in the `v_db_index` VIEW (`table_name`,
24
+ * `column_names` as a bracketed list like "[occurrence_key]", `index_name`,
25
+ * `access_method`). A flag with no index is a FAILURE, not a success with a note.
26
+ *
27
+ * 3. UNIQUENESS ENFORCEMENT IS UNREADABLE. `v_db_index` carries no uniqueness field
28
+ * — every row reads `access_method: btree`, unique or not — and the same view also
29
+ * shows the ordinary reference indexes ServiceNow builds on its own. Presence of
30
+ * an index over the right column is therefore NECESSARY BUT NOT SUFFICIENT, and
31
+ * "uniqueness-enforced" is reported in `unverified` on EVERY status, success
32
+ * included. Only a duplicate-insert test can prove enforcement.
33
+ *
34
+ * 4. A UNIQUE INDEX CANNOT BUILD OVER DUPLICATE VALUES, and the platform fails that
35
+ * ALTER quietly — leaving exactly the lying row above. So the live path scans the
36
+ * column first and ABORTS BEFORE WRITING when values repeat. EMPTY counts as a
37
+ * value: a newly added column that is empty on all 498 existing rows is 498
38
+ * collisions, which is the single most likely way this verb would be used wrong.
39
+ * AN UNPROVEN SCAN IS TREATED EXACTLY LIKE A PROVEN COLLISION. The scan is paged
40
+ * and capped (SCAN_MAX_PAGES x SCAN_PAGE_SIZE rows); if it hits that cap the run
41
+ * ABORTS TOO. Writing on a scan that only got part-way would risk manufacturing
42
+ * the very lying row above — on a table too big to have been checked — and there
43
+ * is no headless way back from it, because writing "true" over "true" fires no
44
+ * ALTER. "Not proven clean" is not "clean".
45
+ *
46
+ * `verified.indexPresent` is deliberately three-valued: `true` (a matching row was
47
+ * read back), `false` (the view WAS read and holds no such row) and `null` (the view
48
+ * could not be read, or was never read). Collapsing `null` into `false` would report
49
+ * "the index is absent" when the truth is "the instrument is blind".
50
+ *
51
+ * `set-column` cannot do this: its WRITABLE allowlist is closed (label, mandatory,
52
+ * default, read_only, max_length) and deliberately excludes `unique`, which has a
53
+ * physical side effect and a verification burden the others do not.
54
+ *
55
+ * ES6 only, no optional chaining, no `any`.
56
+ */
57
+ import type { ServiceNowClient } from "../client";
58
+ export interface AddIndexParams {
59
+ /** REST client for table/scope resolution, the dictionary patch, and the read-back. */
60
+ client: ServiceNowClient;
61
+ /** Existing table — its name ("x_cadso_journey_instance") OR its sys_db_object sys_id. */
62
+ table: string;
63
+ /** The column list. Exactly one entry today; more is REFUSED, never narrowed. */
64
+ columns: Array<string>;
65
+ /** Must be true — there is no dictionary lever for a plain (non-unique) index. */
66
+ unique: true;
67
+ /** Scope name or sys_scope sys_id. Must match the table's own scope. */
68
+ scope?: string;
69
+ /** Update set to capture the dictionary patch into. REQUIRED on the live path. */
70
+ updateSetSysId?: string;
71
+ /** Plan only — no reads, no writes. */
72
+ dryRun?: boolean;
73
+ /** Add diagnostic detail to the result note. */
74
+ debug?: boolean;
75
+ }
76
+ /** What was actually READ BACK. `null` means "not read", never "absent". */
77
+ export interface AddIndexVerification {
78
+ /** sys_dictionary.unique re-read after the patch. null when the re-read failed. */
79
+ dictionaryUnique: boolean | null;
80
+ /** A v_db_index row over exactly these columns. null when the view was not read. */
81
+ indexPresent: boolean | null;
82
+ /** The matching row's `column_names`, verbatim (e.g. "[occurrence_key]"); "" when none. */
83
+ indexColumns: string;
84
+ }
85
+ export interface AddIndexResult {
86
+ status: "created" | "dry-run" | "failed" | "skipped";
87
+ /** The table's name (resolved on the live path; echoes the input on dry-run). */
88
+ table: string;
89
+ /** The requested column list, normalized. */
90
+ columns: Array<string>;
91
+ /** index_name as reported by v_db_index; "" when no matching index was read back. */
92
+ indexName: string;
93
+ verified: AddIndexVerification;
94
+ /** Always includes "uniqueness-enforced". Never empty. */
95
+ unverified: Array<string>;
96
+ /** Update set the patch was captured into ("" on dry-run without one). */
97
+ updateSetSysId: string;
98
+ note: string;
99
+ }
100
+ /** One repeated value and how many ROWS carry it. */
101
+ export interface DuplicateValue {
102
+ value: string;
103
+ rows: number;
104
+ }
105
+ export interface DuplicateScan {
106
+ duplicates: Array<DuplicateValue>;
107
+ /** Rows actually read. */
108
+ scanned: number;
109
+ /**
110
+ * True when the scan hit its page cap — "no duplicates seen" is then not "none exist",
111
+ * so the live path REFUSES to write on it rather than downgrading it to a caveat.
112
+ */
113
+ incomplete: boolean;
114
+ }
115
+ /**
116
+ * Read the column's values across the table and count repeats. EMPTY is counted as a
117
+ * value, not skipped: ServiceNow stores an unset string as "", and "" collides with ""
118
+ * — the 498-rows-with-a-new-empty-column trap. Pages by a sys_id keyset because
119
+ * `client.table.query` exposes no sysparm_offset.
120
+ */
121
+ export declare function scanForDuplicates(client: ServiceNowClient, table: string, column: string): Promise<DuplicateScan>;
122
+ /** Parse a v_db_index `column_names` cell ("[a]", "[a,b]") into its column list. */
123
+ export declare function parseIndexColumns(raw: string): Array<string>;
124
+ /**
125
+ * Does this row's column list match the requested one EXACTLY? Parsed, never
126
+ * substring-matched: "[occurrence_key_extra]" contains "occurrence_key", and an
127
+ * indexOf test would green-light an index over the wrong column.
128
+ */
129
+ export declare function indexMatchesColumns(raw: string, columns: Array<string>): boolean;
130
+ export declare function addIndex(params: AddIndexParams): Promise<AddIndexResult>;
@@ -0,0 +1,588 @@
1
+ "use strict";
2
+ /**
3
+ * addIndex — create a single-column UNIQUE index on an EXISTING ServiceNow table,
4
+ * headless, and then read the result back.
5
+ *
6
+ * THE ONLY HEADLESS LEVER IS `sys_dictionary.unique`. There is no writable index
7
+ * table: `sys_index` fails an API-LEVEL ACL (HTTP 403) for every identity — an ACL
8
+ * that refuses GET refuses POST — and `sys_index_column` does not exist (HTTP 400
9
+ * "Invalid table"). So this verb patches the column's dictionary row through the
10
+ * scope-aware `pushWithUpdateSet` op (update set + scope switched server-side,
11
+ * exactly as `addColumn` does for max_length) and lets the platform build the
12
+ * physical index off that flag.
13
+ *
14
+ * Consequences, each of which the contract encodes rather than hides:
15
+ *
16
+ * 1. PER-COLUMN ONLY. `unique` is a column flag, so a composite index has no
17
+ * dictionary lever at all. A multi-column request is REFUSED, never narrowed to
18
+ * its first column — quietly building a different index than the one asked for is
19
+ * the worst available outcome. Composite / non-unique indexes stay UI work.
20
+ *
21
+ * 2. THE FLAG IS NOT SELF-VERIFYING. `x_cadso_core_metric_point.idempotency_key`
22
+ * reads `unique=true` in the dictionary with NO index on the table — a lying row.
23
+ * So the write is never the proof: after the patch the dictionary row is re-read
24
+ * BY sys_id and the index is looked for in the `v_db_index` VIEW (`table_name`,
25
+ * `column_names` as a bracketed list like "[occurrence_key]", `index_name`,
26
+ * `access_method`). A flag with no index is a FAILURE, not a success with a note.
27
+ *
28
+ * 3. UNIQUENESS ENFORCEMENT IS UNREADABLE. `v_db_index` carries no uniqueness field
29
+ * — every row reads `access_method: btree`, unique or not — and the same view also
30
+ * shows the ordinary reference indexes ServiceNow builds on its own. Presence of
31
+ * an index over the right column is therefore NECESSARY BUT NOT SUFFICIENT, and
32
+ * "uniqueness-enforced" is reported in `unverified` on EVERY status, success
33
+ * included. Only a duplicate-insert test can prove enforcement.
34
+ *
35
+ * 4. A UNIQUE INDEX CANNOT BUILD OVER DUPLICATE VALUES, and the platform fails that
36
+ * ALTER quietly — leaving exactly the lying row above. So the live path scans the
37
+ * column first and ABORTS BEFORE WRITING when values repeat. EMPTY counts as a
38
+ * value: a newly added column that is empty on all 498 existing rows is 498
39
+ * collisions, which is the single most likely way this verb would be used wrong.
40
+ * AN UNPROVEN SCAN IS TREATED EXACTLY LIKE A PROVEN COLLISION. The scan is paged
41
+ * and capped (SCAN_MAX_PAGES x SCAN_PAGE_SIZE rows); if it hits that cap the run
42
+ * ABORTS TOO. Writing on a scan that only got part-way would risk manufacturing
43
+ * the very lying row above — on a table too big to have been checked — and there
44
+ * is no headless way back from it, because writing "true" over "true" fires no
45
+ * ALTER. "Not proven clean" is not "clean".
46
+ *
47
+ * `verified.indexPresent` is deliberately three-valued: `true` (a matching row was
48
+ * read back), `false` (the view WAS read and holds no such row) and `null` (the view
49
+ * could not be read, or was never read). Collapsing `null` into `false` would report
50
+ * "the index is absent" when the truth is "the instrument is blind".
51
+ *
52
+ * `set-column` cannot do this: its WRITABLE allowlist is closed (label, mandatory,
53
+ * default, read_only, max_length) and deliberately excludes `unique`, which has a
54
+ * physical side effect and a verification burden the others do not.
55
+ *
56
+ * ES6 only, no optional chaining, no `any`.
57
+ */
58
+ Object.defineProperty(exports, "__esModule", { value: true });
59
+ exports.scanForDuplicates = scanForDuplicates;
60
+ exports.parseIndexColumns = parseIndexColumns;
61
+ exports.indexMatchesColumns = indexMatchesColumns;
62
+ exports.addIndex = addIndex;
63
+ const setField_1 = require("../setField");
64
+ const choices_1 = require("../choices");
65
+ var SYS_ID = /^[0-9a-f]{32}$/i;
66
+ /** A dictionary element is a plain identifier. Anything else is rejected before it
67
+ * reaches an encoded query or a sysparm_fields list. */
68
+ var COLUMN_NAME = /^[A-Za-z][A-Za-z0-9_]*$/;
69
+ /** ServiceNow caps a Table API page at 1000 rows, and there is no sysparm_offset on
70
+ * the client — the duplicate scan pages by a sys_id keyset instead. */
71
+ var SCAN_PAGE_SIZE = 1000;
72
+ var SCAN_MAX_PAGES = 10;
73
+ var INDEX_ROW_LIMIT = 500;
74
+ var SAMPLE_VALUE_MAX = 80;
75
+ var MAX_DUPLICATE_SAMPLES = 5;
76
+ /** Never provable from a read — see header note 3. Present on every status. */
77
+ var UNIQUENESS_UNVERIFIABLE = "uniqueness-enforced";
78
+ /** Set when v_db_index was not read at all (dry-run) or could not be read (403). */
79
+ var INDEX_PRESENCE_UNVERIFIED = "index-presence";
80
+ function errorMessage(e) {
81
+ if (e instanceof Error && e.message)
82
+ return e.message;
83
+ if (typeof e === "string" && e)
84
+ return e;
85
+ return String(e);
86
+ }
87
+ function isTrue(value) {
88
+ var v = value.toLowerCase();
89
+ return v === "true" || v === "1";
90
+ }
91
+ /** Validate every input BEFORE any network call, and return the single column name. */
92
+ function validate(params) {
93
+ if (!params || typeof params !== "object")
94
+ throw new Error("add-index: params object required.");
95
+ if (!params.client)
96
+ throw new Error("add-index: client is required.");
97
+ if (typeof params.table !== "string" || !params.table.trim())
98
+ throw new Error("add-index: table is required.");
99
+ var columns = params.columns;
100
+ var count = Array.isArray(columns) ? columns.length : 0;
101
+ if (!Array.isArray(columns) || count !== 1) {
102
+ throw new Error("add-index: exactly one column is required (got " +
103
+ count +
104
+ "). sys_dictionary.unique is a PER-COLUMN flag, so there is no headless lever " +
105
+ "for a composite index — and taking the first column would build a DIFFERENT " +
106
+ "index than the one asked for. Create a composite index in the platform UI.");
107
+ }
108
+ var raw = columns[0];
109
+ if (typeof raw !== "string" || !raw.trim())
110
+ throw new Error("add-index: column must be a non-empty string.");
111
+ var column = raw.trim();
112
+ if (!COLUMN_NAME.test(column)) {
113
+ throw new Error("add-index: column '" +
114
+ column +
115
+ "' is not a valid column name — letters, digits and underscores only, " +
116
+ "starting with a letter.");
117
+ }
118
+ if (params.unique !== true) {
119
+ throw new Error("add-index: only a unique index can be created headlessly. The sole lever is " +
120
+ "sys_dictionary.unique, which has no equivalent for a plain (non-unique) index " +
121
+ "— pass unique:true, or create that index in the platform UI.");
122
+ }
123
+ if (params.dryRun !== true &&
124
+ (!params.updateSetSysId || !String(params.updateSetSysId).trim())) {
125
+ throw new Error("add-index: updateSetSysId is required on the live path so the sys_dictionary " +
126
+ "change is captured in a known update set (dry-run does not need one).");
127
+ }
128
+ return column;
129
+ }
130
+ /** Resolve the table by name or sys_id; returns its name, sys_id, and sys_scope sys_id. */
131
+ async function resolveTable(client, table) {
132
+ var query = SYS_ID.test(table)
133
+ ? "sys_id=" + (0, choices_1.encodeQueryValue)(table)
134
+ : "name=" + (0, choices_1.encodeQueryValue)(table);
135
+ var rows = await client.table.query("sys_db_object", query, { limit: 1, fields: ["sys_id", "name", "sys_scope"] });
136
+ if (rows.length === 0) {
137
+ throw new Error("add-index: table '" + table + "' not found in sys_db_object.");
138
+ }
139
+ return {
140
+ name: (0, setField_1.fieldToString)(rows[0].name) || table,
141
+ sysId: (0, setField_1.fieldToString)(rows[0].sys_id),
142
+ scopeSysId: (0, setField_1.fieldToString)(rows[0].sys_scope),
143
+ };
144
+ }
145
+ /** Resolve a sys_scope sys_id to its scope NAME (e.g. "x_cadso_journey"). */
146
+ async function resolveScopeName(client, scopeSysId) {
147
+ if (!scopeSysId)
148
+ return "";
149
+ var rows = await client.table.query("sys_scope", "sys_id=" + (0, choices_1.encodeQueryValue)(scopeSysId), { limit: 1, fields: ["scope"] });
150
+ return rows.length > 0 ? (0, setField_1.fieldToString)(rows[0].scope) : "";
151
+ }
152
+ /**
153
+ * Read the column's values across the table and count repeats. EMPTY is counted as a
154
+ * value, not skipped: ServiceNow stores an unset string as "", and "" collides with ""
155
+ * — the 498-rows-with-a-new-empty-column trap. Pages by a sys_id keyset because
156
+ * `client.table.query` exposes no sysparm_offset.
157
+ */
158
+ async function scanForDuplicates(client, table, column) {
159
+ var counts = new Map();
160
+ var cursor = "";
161
+ var scanned = 0;
162
+ var incomplete = false;
163
+ for (var page = 0; page < SCAN_MAX_PAGES; page += 1) {
164
+ var query = (cursor ? "sys_id>" + (0, choices_1.encodeQueryValue)(cursor) + "^" : "") +
165
+ "ORDERBYsys_id";
166
+ var rows = await client.table.query(table, query, {
167
+ limit: SCAN_PAGE_SIZE,
168
+ fields: ["sys_id", column],
169
+ });
170
+ if (!Array.isArray(rows) || rows.length === 0)
171
+ break;
172
+ var moved = false;
173
+ for (var i = 0; i < rows.length; i += 1) {
174
+ var row = rows[i] && typeof rows[i] === "object" ? rows[i] : {};
175
+ var value = (0, setField_1.fieldToString)(row[column]);
176
+ var seen = counts.get(value);
177
+ counts.set(value, (seen === undefined ? 0 : seen) + 1);
178
+ var sysId = (0, setField_1.fieldToString)(row.sys_id);
179
+ if (sysId && sysId !== cursor) {
180
+ cursor = sysId;
181
+ moved = true;
182
+ }
183
+ }
184
+ scanned += rows.length;
185
+ if (rows.length < SCAN_PAGE_SIZE)
186
+ break;
187
+ // A full page that did not advance the cursor would re-read itself forever.
188
+ if (!moved || page === SCAN_MAX_PAGES - 1) {
189
+ incomplete = true;
190
+ break;
191
+ }
192
+ }
193
+ var duplicates = [];
194
+ counts.forEach(function (rowCount, value) {
195
+ if (rowCount > 1)
196
+ duplicates.push({ value: value, rows: rowCount });
197
+ });
198
+ duplicates.sort(function (a, b) {
199
+ return b.rows - a.rows;
200
+ });
201
+ return { duplicates: duplicates, scanned: scanned, incomplete: incomplete };
202
+ }
203
+ function sampleValue(value) {
204
+ if (value === "")
205
+ return "(empty)";
206
+ if (value.length <= SAMPLE_VALUE_MAX)
207
+ return "'" + value + "'";
208
+ return "'" + value.slice(0, SAMPLE_VALUE_MAX) + "' (truncated)";
209
+ }
210
+ function describeDuplicates(scan) {
211
+ var collidingRows = 0;
212
+ scan.duplicates.forEach(function (d) {
213
+ collidingRows += d.rows;
214
+ });
215
+ var shown = scan.duplicates.slice(0, MAX_DUPLICATE_SAMPLES).map(function (d) {
216
+ return sampleValue(d.value) + " (" + d.rows + " rows)";
217
+ });
218
+ return (scan.duplicates.length +
219
+ " duplicated value(s) across " +
220
+ collidingRows +
221
+ " rows, of " +
222
+ scan.scanned +
223
+ " scanned: " +
224
+ shown.join(", ") +
225
+ (scan.duplicates.length > shown.length ? ", ..." : ""));
226
+ }
227
+ /** Parse a v_db_index `column_names` cell ("[a]", "[a,b]") into its column list. */
228
+ function parseIndexColumns(raw) {
229
+ var text = String(raw === undefined || raw === null ? "" : raw).trim();
230
+ if (text.length > 1 &&
231
+ text.charAt(0) === "[" &&
232
+ text.charAt(text.length - 1) === "]") {
233
+ text = text.slice(1, text.length - 1);
234
+ }
235
+ if (!text)
236
+ return [];
237
+ return text
238
+ .split(",")
239
+ .map(function (part) {
240
+ return part.trim();
241
+ })
242
+ .filter(function (part) {
243
+ return part.length > 0;
244
+ });
245
+ }
246
+ /**
247
+ * Does this row's column list match the requested one EXACTLY? Parsed, never
248
+ * substring-matched: "[occurrence_key_extra]" contains "occurrence_key", and an
249
+ * indexOf test would green-light an index over the wrong column.
250
+ */
251
+ function indexMatchesColumns(raw, columns) {
252
+ var parsed = parseIndexColumns(raw);
253
+ if (parsed.length !== columns.length)
254
+ return false;
255
+ for (var i = 0; i < parsed.length; i += 1) {
256
+ if (parsed[i].toLowerCase() !== String(columns[i]).toLowerCase()) {
257
+ return false;
258
+ }
259
+ }
260
+ return true;
261
+ }
262
+ /**
263
+ * Read the index back from `v_db_index`. `sys_index` is never touched: it is
264
+ * API-level-ACL 403 to every identity, so reading it would turn a blind instrument
265
+ * into a hard failure of the whole verb.
266
+ */
267
+ async function readIndexBack(client, table, columns) {
268
+ var rows;
269
+ try {
270
+ rows = await client.table.query("v_db_index", "table_name=" + (0, choices_1.encodeQueryValue)(table), {
271
+ limit: INDEX_ROW_LIMIT,
272
+ fields: ["table_name", "index_name", "column_names", "access_method"],
273
+ });
274
+ }
275
+ catch (e) {
276
+ return {
277
+ readable: false,
278
+ error: errorMessage(e),
279
+ present: false,
280
+ indexName: "",
281
+ indexColumns: "",
282
+ others: [],
283
+ };
284
+ }
285
+ var others = [];
286
+ var match;
287
+ var safeRows = Array.isArray(rows) ? rows : [];
288
+ for (var i = 0; i < safeRows.length; i += 1) {
289
+ var row = safeRows[i] && typeof safeRows[i] === "object" ? safeRows[i] : {};
290
+ if ((0, setField_1.fieldToString)(row.table_name) !== table)
291
+ continue;
292
+ var cols = (0, setField_1.fieldToString)(row.column_names);
293
+ if (!match && indexMatchesColumns(cols, columns)) {
294
+ match = row;
295
+ }
296
+ else {
297
+ others.push((0, setField_1.fieldToString)(row.index_name) + " over " + cols);
298
+ }
299
+ }
300
+ return {
301
+ readable: true,
302
+ error: "",
303
+ present: Boolean(match),
304
+ indexName: match ? (0, setField_1.fieldToString)(match.index_name) : "",
305
+ indexColumns: match ? (0, setField_1.fieldToString)(match.column_names) : "",
306
+ others: others,
307
+ };
308
+ }
309
+ function finish(state, status, note) {
310
+ return {
311
+ status: status,
312
+ table: state.table,
313
+ columns: state.columns.slice(),
314
+ indexName: state.indexName,
315
+ verified: {
316
+ dictionaryUnique: state.dictionaryUnique,
317
+ indexPresent: state.indexPresent,
318
+ indexColumns: state.indexColumns,
319
+ },
320
+ unverified: [UNIQUENESS_UNVERIFIABLE].concat(state.extraUnverified),
321
+ updateSetSysId: state.updateSetSysId,
322
+ note: note,
323
+ };
324
+ }
325
+ /** The read-back verdict, shared by the "we wrote" and "already set" paths. */
326
+ function verdict(state, readBack, wrote, table, column, extraNote) {
327
+ var target = table + "." + column;
328
+ var caveat = " NOT VERIFIED: that the index actually REJECTS duplicates — v_db_index carries no " +
329
+ "uniqueness field (every row reads btree, unique or not), so enforcement is provable " +
330
+ "only by a duplicate-insert test.";
331
+ if (!readBack.readable) {
332
+ state.indexPresent = null;
333
+ state.extraUnverified.push(INDEX_PRESENCE_UNVERIFIED);
334
+ return finish(state, "failed", (wrote
335
+ ? "sys_dictionary.unique=true was written for " + target + ", but "
336
+ : target + " already reads unique=true in sys_dictionary, but ") +
337
+ "v_db_index could not be read — index presence is UNKNOWN, which is NOT the " +
338
+ "same as absent: " +
339
+ readBack.error +
340
+ ". Re-run the read-back with an identity that can read v_db_index before " +
341
+ "treating this index as built." +
342
+ caveat +
343
+ extraNote);
344
+ }
345
+ state.indexPresent = readBack.present;
346
+ state.indexName = readBack.indexName;
347
+ state.indexColumns = readBack.indexColumns;
348
+ if (!readBack.present) {
349
+ return finish(state, "failed", (wrote
350
+ ? "sys_dictionary.unique=true was written for " +
351
+ target +
352
+ ", but the index is "
353
+ : target +
354
+ " already reads unique=true in sys_dictionary, but the index is ") +
355
+ "NOT OBSERVED in v_db_index" +
356
+ (readBack.others.length > 0
357
+ ? " (the table's other indexes: " + readBack.others.join("; ") + ")"
358
+ : " (the view reports no index at all for this table)") +
359
+ ". This is the lying-row case — a dictionary flag with no physical index " +
360
+ "behind it, which enforces nothing. " +
361
+ (wrote
362
+ ? "The most likely cause is duplicate values the ALTER could not build over."
363
+ : "Writing 'true' over 'true' fires no ALTER, so nothing was written and " +
364
+ "add-index cannot repair this: clear the duplicates and rebuild the index " +
365
+ "in the platform UI.") +
366
+ caveat +
367
+ extraNote);
368
+ }
369
+ return finish(state, wrote ? "created" : "skipped", (wrote
370
+ ? "Set sys_dictionary.unique=true on " +
371
+ target +
372
+ " and READ BACK a matching index in v_db_index: "
373
+ : target +
374
+ " already reads unique=true in sys_dictionary and v_db_index already holds a " +
375
+ "matching index — nothing was written: ") +
376
+ (readBack.indexName || "(unnamed)") +
377
+ " over " +
378
+ readBack.indexColumns +
379
+ "." +
380
+ caveat +
381
+ extraNote);
382
+ }
383
+ async function addIndex(params) {
384
+ var column = validate(params);
385
+ var client = params.client;
386
+ if (params.dryRun) {
387
+ return {
388
+ status: "dry-run",
389
+ table: params.table,
390
+ columns: [column],
391
+ indexName: "",
392
+ verified: {
393
+ dictionaryUnique: false,
394
+ indexPresent: null,
395
+ indexColumns: "",
396
+ },
397
+ unverified: [UNIQUENESS_UNVERIFIABLE, INDEX_PRESENCE_UNVERIFIED],
398
+ updateSetSysId: params.updateSetSysId ? params.updateSetSysId : "",
399
+ note: "dry-run: nothing written and nothing read. Would set sys_dictionary.unique=true " +
400
+ "on '" +
401
+ params.table +
402
+ "." +
403
+ column +
404
+ "' — the only headless lever for an index — captured into update set " +
405
+ (params.updateSetSysId ? params.updateSetSysId : "(none provided)") +
406
+ ", then re-read the dictionary row and look for an index over [" +
407
+ column +
408
+ "] in v_db_index. A dry-run does NOT check that the column exists, that its " +
409
+ "values are free of duplicates (a unique index cannot build over them, empty " +
410
+ "values included), or that an index is already there — the live path does all " +
411
+ "three before it writes.",
412
+ };
413
+ }
414
+ // ---- LIVE PATH ------------------------------------------------------------
415
+ var updateSetSysId = String(params.updateSetSysId).trim();
416
+ var resolved = await resolveTable(client, params.table);
417
+ var scopeName = await resolveScopeName(client, resolved.scopeSysId);
418
+ if (!scopeName) {
419
+ throw new Error("add-index: could not resolve the scope name for table '" +
420
+ resolved.name +
421
+ "' (sys_scope " +
422
+ (resolved.scopeSysId || "(none)") +
423
+ ") — needed to confirm the column is written in its own scope.");
424
+ }
425
+ // A column lives in its table's scope; an explicit override must match it.
426
+ if (params.scope && params.scope.trim()) {
427
+ var override = params.scope.trim();
428
+ if (override !== scopeName && override !== resolved.scopeSysId) {
429
+ throw new Error("add-index: --scope '" +
430
+ override +
431
+ "' does not match table '" +
432
+ resolved.name +
433
+ "' scope '" +
434
+ scopeName +
435
+ "' — an index lives in its table's scope. Omit --scope or set it to '" +
436
+ scopeName +
437
+ "'.");
438
+ }
439
+ }
440
+ var state = {
441
+ table: resolved.name,
442
+ columns: [column],
443
+ updateSetSysId: updateSetSysId,
444
+ dictionaryUnique: false,
445
+ indexPresent: null,
446
+ indexColumns: "",
447
+ indexName: "",
448
+ extraUnverified: [],
449
+ };
450
+ var dictRows = await client.table.query("sys_dictionary", "name=" +
451
+ (0, choices_1.encodeQueryValue)(resolved.name) +
452
+ "^element=" +
453
+ (0, choices_1.encodeQueryValue)(column), {
454
+ limit: 1,
455
+ fields: ["sys_id", "name", "element", "internal_type", "unique"],
456
+ });
457
+ if (dictRows.length === 0) {
458
+ return finish(state, "failed", "column '" +
459
+ column +
460
+ "' does not exist on " +
461
+ resolved.name +
462
+ " — no sys_dictionary row for name=" +
463
+ resolved.name +
464
+ "^element=" +
465
+ column +
466
+ ". Nothing was written. Add the column first (dove-sn add-column), backfill it, " +
467
+ "then index it.");
468
+ }
469
+ var dictSysId = (0, setField_1.fieldToString)(dictRows[0].sys_id);
470
+ var internalType = (0, setField_1.fieldToString)(dictRows[0].internal_type);
471
+ if (!dictSysId) {
472
+ // The patch targets the dictionary row BY sys_id. Without one there is nothing to
473
+ // aim at, and sending an empty record_sys_id would write blind.
474
+ return finish(state, "failed", "the sys_dictionary row for " +
475
+ resolved.name +
476
+ "." +
477
+ column +
478
+ " came back without a sys_id, so there is no row to patch. Nothing was " +
479
+ "written — check the column (and the caller's read access to sys_dictionary) " +
480
+ "on the instance.");
481
+ }
482
+ var alreadyUnique = isTrue((0, setField_1.fieldToString)(dictRows[0].unique));
483
+ state.dictionaryUnique = alreadyUnique;
484
+ var debugNote = params.debug
485
+ ? " [debug: dictionarySysId=" +
486
+ dictSysId +
487
+ " internalType=" +
488
+ (internalType || "(none)") +
489
+ " scope=" +
490
+ scopeName +
491
+ " tableSysId=" +
492
+ resolved.sysId +
493
+ "]"
494
+ : "";
495
+ // Already flagged: writing "true" over "true" fires no ALTER, so there is nothing
496
+ // this verb can do — go straight to the read-back and report what is actually there.
497
+ if (alreadyUnique) {
498
+ var existing = await readIndexBack(client, resolved.name, [column]);
499
+ return verdict(state, existing, false, resolved.name, column, debugNote);
500
+ }
501
+ // A unique index cannot build over duplicates, and the failed ALTER is SILENT —
502
+ // it leaves the dictionary claiming unique=true with no index behind it. Check first.
503
+ var scan = await scanForDuplicates(client, resolved.name, column);
504
+ if (scan.duplicates.length > 0) {
505
+ return finish(state, "failed", "Refusing to write: a unique index cannot build over duplicate values, and " +
506
+ resolved.name +
507
+ "." +
508
+ column +
509
+ " holds them — the platform would fail the ALTER silently and leave " +
510
+ "sys_dictionary claiming unique=true with NO index behind it (the " +
511
+ "x_cadso_core_metric_point.idempotency_key trap). " +
512
+ describeDuplicates(scan) +
513
+ ". An EMPTY value counts: every empty row collides with every other empty row. " +
514
+ "Nothing was written — backfill or clear those rows, then re-run." +
515
+ debugNote);
516
+ }
517
+ // The scan is capped. A capped scan proves NOTHING about the rows it never read, and
518
+ // writing on "no duplicates in the part I saw" is how this verb would manufacture the
519
+ // lying row it exists to prevent — on a table too big for anyone to have checked, with
520
+ // no headless way back (writing "true" over "true" fires no ALTER). So an unproven scan
521
+ // aborts exactly like a proven collision does.
522
+ if (scan.incomplete) {
523
+ return finish(state, "failed", "Refusing to write: the duplicate scan could not read the whole column. It " +
524
+ "stopped after " +
525
+ scan.scanned +
526
+ " rows of " +
527
+ resolved.name +
528
+ "." +
529
+ column +
530
+ ", so freedom from duplicates is UNPROVEN beyond that depth — and a unique " +
531
+ "index that meets a collision fails its ALTER SILENTLY, leaving sys_dictionary " +
532
+ "claiming unique=true with NO index behind it (the " +
533
+ "x_cadso_core_metric_point.idempotency_key trap) and no headless way back, " +
534
+ "because writing 'true' over 'true' fires no ALTER. Nothing was written — " +
535
+ "prove the column is duplicate-free by another route (EMPTY counts as a value) " +
536
+ "and build this index in the platform UI." +
537
+ debugNote);
538
+ }
539
+ try {
540
+ await client.claude.pushWithUpdateSet({
541
+ update_set_sys_id: updateSetSysId,
542
+ table: "sys_dictionary",
543
+ record_sys_id: dictSysId,
544
+ fields: { unique: "true" },
545
+ });
546
+ }
547
+ catch (e) {
548
+ return finish(state, "failed", "The sys_dictionary patch FAILED, so nothing changed: " +
549
+ errorMessage(e) +
550
+ ". " +
551
+ resolved.name +
552
+ "." +
553
+ column +
554
+ " still read unique=false before the attempt; no index was created and no " +
555
+ "update-set entry was captured." +
556
+ debugNote);
557
+ }
558
+ // Read the flag back BY sys_id — the write returning 200 is not evidence the row
559
+ // changed, and it is certainly not evidence an index was built.
560
+ try {
561
+ var after = await client.table.query("sys_dictionary", "sys_id=" + (0, choices_1.encodeQueryValue)(dictSysId), { limit: 1, fields: ["sys_id", "element", "internal_type", "unique"] });
562
+ state.dictionaryUnique =
563
+ after.length > 0 ? isTrue((0, setField_1.fieldToString)(after[0].unique)) : false;
564
+ }
565
+ catch (e) {
566
+ state.dictionaryUnique = null;
567
+ return finish(state, "failed", "The sys_dictionary patch was accepted for " +
568
+ resolved.name +
569
+ "." +
570
+ column +
571
+ ", but re-reading the row failed, so nothing about this run is verified: " +
572
+ errorMessage(e) +
573
+ ". Check the column on the instance before relying on it." +
574
+ debugNote);
575
+ }
576
+ if (state.dictionaryUnique !== true) {
577
+ return finish(state, "failed", "The sys_dictionary patch was accepted for " +
578
+ resolved.name +
579
+ "." +
580
+ column +
581
+ ", but the row still does NOT read unique=true on read-back — the flag did not " +
582
+ "stick, so no index was built. Check the column (and the update set) on the " +
583
+ "instance." +
584
+ debugNote);
585
+ }
586
+ var readBack = await readIndexBack(client, resolved.name, [column]);
587
+ return verdict(state, readBack, true, resolved.name, column, " Captured into update set " + updateSetSysId + "." + debugNote);
588
+ }
@@ -13,6 +13,8 @@ export { resolveFormAuth, openFormSession, setCurrentApplication, getRecordForm,
13
13
  export type { FormAuth, FormSession, HarvestedForm, PostResult, } from "./formSession";
14
14
  export { addColumn, deriveElement } from "./addColumn";
15
15
  export type { AddColumnParams, AddColumnResult } from "./addColumn";
16
+ export { addIndex, scanForDuplicates, parseIndexColumns, indexMatchesColumns, } from "./addIndex";
17
+ export type { AddIndexParams, AddIndexResult, AddIndexVerification, DuplicateScan, DuplicateValue, } from "./addIndex";
16
18
  export { setColumn, resolveAttributes, toStoredValue, findTruncationRisk, } from "./setColumn";
17
19
  export type { SetColumnParams, SetColumnResult, ColumnAttributes, AttributeChange, TruncationRisk, } from "./setColumn";
18
20
  export { OVERRIDABLE, LABEL_LANGUAGE, explainMaxLengthNotOverridable, resolveTableScope, findOverrideRow, findLabelRow, effectiveValue, diffInherited, applyInheritedWrites, overrideUpdateName, labelUpdateName, } from "./overrideColumn";
@@ -5,7 +5,7 @@
5
5
  * live-validation caveat.
6
6
  */
7
7
  Object.defineProperty(exports, "__esModule", { value: true });
8
- exports.resolveTableAttributes = exports.setTable = exports.labelUpdateName = exports.overrideUpdateName = exports.applyInheritedWrites = exports.diffInherited = exports.effectiveValue = exports.findLabelRow = exports.findOverrideRow = exports.resolveTableScope = exports.explainMaxLengthNotOverridable = exports.LABEL_LANGUAGE = exports.OVERRIDABLE = exports.findTruncationRisk = exports.toStoredValue = exports.resolveAttributes = exports.setColumn = exports.deriveElement = exports.addColumn = exports.decodeHtmlEntities = exports.scrapeCk = exports.postForm = exports.parseFormInputs = exports.getNewRecordForm = exports.getRecordForm = exports.setCurrentApplication = exports.openFormSession = exports.resolveFormAuth = exports.TYPE_MAP = exports.listEditKey = exports.showInMenuKey = exports.defaultAccessFlags = exports.applyTableSaveOverlay = exports.resolveType = exports.normalizeColumns = exports.xmlEscape = exports.buildColumnXml = exports.DEFAULT_COLUMNS_REL_ID = exports.DEFAULT_SAVE_ACTION = exports.DEFAULT_SUPER_CLASS = exports.parseSysIdFromLocation = exports.projectTableGraph = exports.createTable = void 0;
8
+ exports.resolveTableAttributes = exports.setTable = exports.labelUpdateName = exports.overrideUpdateName = exports.applyInheritedWrites = exports.diffInherited = exports.effectiveValue = exports.findLabelRow = exports.findOverrideRow = exports.resolveTableScope = exports.explainMaxLengthNotOverridable = exports.LABEL_LANGUAGE = exports.OVERRIDABLE = exports.findTruncationRisk = exports.toStoredValue = exports.resolveAttributes = exports.setColumn = exports.indexMatchesColumns = exports.parseIndexColumns = exports.scanForDuplicates = exports.addIndex = exports.deriveElement = exports.addColumn = exports.decodeHtmlEntities = exports.scrapeCk = exports.postForm = exports.parseFormInputs = exports.getNewRecordForm = exports.getRecordForm = exports.setCurrentApplication = exports.openFormSession = exports.resolveFormAuth = exports.TYPE_MAP = exports.listEditKey = exports.showInMenuKey = exports.defaultAccessFlags = exports.applyTableSaveOverlay = exports.resolveType = exports.normalizeColumns = exports.xmlEscape = exports.buildColumnXml = exports.DEFAULT_COLUMNS_REL_ID = exports.DEFAULT_SAVE_ACTION = exports.DEFAULT_SUPER_CLASS = exports.parseSysIdFromLocation = exports.projectTableGraph = exports.createTable = void 0;
9
9
  var createTable_1 = require("./createTable");
10
10
  Object.defineProperty(exports, "createTable", { enumerable: true, get: function () { return createTable_1.createTable; } });
11
11
  Object.defineProperty(exports, "projectTableGraph", { enumerable: true, get: function () { return createTable_1.projectTableGraph; } });
@@ -37,6 +37,11 @@ Object.defineProperty(exports, "decodeHtmlEntities", { enumerable: true, get: fu
37
37
  var addColumn_1 = require("./addColumn");
38
38
  Object.defineProperty(exports, "addColumn", { enumerable: true, get: function () { return addColumn_1.addColumn; } });
39
39
  Object.defineProperty(exports, "deriveElement", { enumerable: true, get: function () { return addColumn_1.deriveElement; } });
40
+ var addIndex_1 = require("./addIndex");
41
+ Object.defineProperty(exports, "addIndex", { enumerable: true, get: function () { return addIndex_1.addIndex; } });
42
+ Object.defineProperty(exports, "scanForDuplicates", { enumerable: true, get: function () { return addIndex_1.scanForDuplicates; } });
43
+ Object.defineProperty(exports, "parseIndexColumns", { enumerable: true, get: function () { return addIndex_1.parseIndexColumns; } });
44
+ Object.defineProperty(exports, "indexMatchesColumns", { enumerable: true, get: function () { return addIndex_1.indexMatchesColumns; } });
40
45
  var setColumn_1 = require("./setColumn");
41
46
  Object.defineProperty(exports, "setColumn", { enumerable: true, get: function () { return setColumn_1.setColumn; } });
42
47
  Object.defineProperty(exports, "resolveAttributes", { enumerable: true, get: function () { return setColumn_1.resolveAttributes; } });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tenonhq/dovetail-servicenow",
3
- "version": "0.0.36",
3
+ "version": "0.0.37",
4
4
  "engines": {
5
5
  "node": ">=22"
6
6
  },