@tenonhq/dovetail-core 0.0.122 → 0.0.123

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
@@ -35,6 +35,14 @@ npx dove migrate # migrate a Sincronia project to Dovetail (dry-run by def
35
35
 
36
36
  See [`UPDATE_SET_COMMANDS.md`](UPDATE_SET_COMMANDS.md) for the full update-set CLI surface.
37
37
 
38
+ ### `dove create sys_update_set`
39
+
40
+ `npx dove create <table>` inserts through the generic `createRecord` endpoint — except for `sys_update_set`. A plain insert lets ServiceNow default the set's `application` to the API session's current app, so `--scope` was silently ignored and the set landed in whatever scope the session was in (TenonHQ/Dovetail#231). Update sets therefore route through the atomic, scope-correct `createUpdateSet` server op (`POST /api/cadso/dovetail_core/createUpdateSet`) and the created set is read back: if its `application` does not match the requested scope the command exits non-zero, naming the requested vs. actual scope and the set's sys_id, so a mis-scoped set is never reported as success.
41
+
42
+ - `--scope` decides the application. It is **required** with `--ci`; interactively it is confirmed in the summary.
43
+ - Only `name` and `description` map onto the op; other `--field` values are ignored with a warning.
44
+ - The set is created, not activated — use `npx dove switchUpdateSet --name "<name>" -s <scope>` (or `npx dove createUpdateSet`, which creates and activates in one step).
45
+
38
46
  ## Plugins
39
47
 
40
48
  Dovetail's build pipeline is plugin-driven. Each plugin is an npm package implementing `run(context, content, options) => Promise<PluginResults>`. Shipped plugins live in sibling packages (`@tenonhq/dovetail-typescript-plugin`, `-babel-plugin`, `-webpack-plugin`, `-sass-plugin`, `-eslint-plugin`, `-prettier-plugin`, `-babel-preset-servicenow`, `-babel-plugin-remove-modules`).
package/dist/commander.js CHANGED
@@ -214,7 +214,7 @@ function configureCli(cli) {
214
214
  await (0, commands_1.buildCommand)(args);
215
215
  })
216
216
  .command("deploy", "Deploy local build files to the scoped application", sharedOptions, commands_1.deployCommand)
217
- .command("create <table>", "Create a new record on the ServiceNow instance", (cmdArgs) => {
217
+ .command("create <table>", "Create a new record on the ServiceNow instance (sys_update_set goes through the scope-correct createUpdateSet op; --scope decides its application)", (cmdArgs) => {
218
218
  cmdArgs.positional("table", {
219
219
  describe: "ServiceNow table name (e.g., sys_script_include)",
220
220
  type: "string",
@@ -229,7 +229,7 @@ function configureCli(cli) {
229
229
  scope: {
230
230
  alias: "s",
231
231
  type: "string",
232
- describe: "Target scope (e.g., x_cadso_core)",
232
+ describe: "Target scope (e.g., x_cadso_core). For sys_update_set this decides the set's application and is required with --ci",
233
233
  },
234
234
  from: {
235
235
  alias: "f",
@@ -36,6 +36,8 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
36
36
  return (mod && mod.__esModule) ? mod : { "default": mod };
37
37
  };
38
38
  Object.defineProperty(exports, "__esModule", { value: true });
39
+ exports.UpdateSetScopeMismatchError = exports.UPDATE_SET_TABLE = void 0;
40
+ exports.createUpdateSetRecord = createUpdateSetRecord;
39
41
  exports.createRecordCommand = createRecordCommand;
40
42
  const fs_1 = __importDefault(require("fs"));
41
43
  const path_1 = __importDefault(require("path"));
@@ -48,6 +50,55 @@ const commands_1 = require("./commands");
48
50
  const ConfigManager = __importStar(require("./config"));
49
51
  const AppUtils = __importStar(require("./appUtils"));
50
52
  const projectFiles_1 = require("./projectFiles");
53
+ /**
54
+ * Table whose rows must NOT go through the generic record insert. A plain
55
+ * insert lets ServiceNow default `application` to the API session's current
56
+ * app (gs.getCurrentApplicationId()), so `--scope` was silently ignored and
57
+ * the set landed in whatever scope the session happened to be in
58
+ * (TenonHQ/Dovetail#231). Rows for this table route through the atomic
59
+ * `createUpdateSet` server op instead.
60
+ */
61
+ exports.UPDATE_SET_TABLE = "sys_update_set";
62
+ /** Fields `dove create` can map onto the createUpdateSet server op. */
63
+ const UPDATE_SET_OP_FIELDS = ["name", "description"];
64
+ /** Scope names are identifiers (`x_cadso_core`, `global`) — nothing else. */
65
+ const SCOPE_NAME_PATTERN = /^[A-Za-z0-9_]+$/;
66
+ /**
67
+ * Raised when a created update set is readable but its `application` does
68
+ * not match the requested scope. Carries both sides so callers can print an
69
+ * actionable message and exit non-zero instead of claiming success.
70
+ */
71
+ class UpdateSetScopeMismatchError extends Error {
72
+ updateSetSysId;
73
+ requestedScope;
74
+ requestedScopeSysId;
75
+ actualScope;
76
+ actualScopeSysId;
77
+ constructor(details) {
78
+ super('Update set "' +
79
+ details.updateSetName +
80
+ '" (' +
81
+ details.updateSetSysId +
82
+ ") landed in scope " +
83
+ details.actualScope +
84
+ " (" +
85
+ details.actualScopeSysId +
86
+ ") but --scope " +
87
+ details.requestedScope +
88
+ " (" +
89
+ details.requestedScopeSysId +
90
+ ") was requested. The set is MIS-SCOPED: delete it on the instance " +
91
+ "before it collects captures, then confirm the Dovetail " +
92
+ "createUpdateSet REST op is installed on this instance and retry.");
93
+ this.name = "UpdateSetScopeMismatchError";
94
+ this.updateSetSysId = details.updateSetSysId;
95
+ this.requestedScope = details.requestedScope;
96
+ this.requestedScopeSysId = details.requestedScopeSysId;
97
+ this.actualScope = details.actualScope;
98
+ this.actualScopeSysId = details.actualScopeSysId;
99
+ }
100
+ }
101
+ exports.UpdateSetScopeMismatchError = UpdateSetScopeMismatchError;
51
102
  const getUpdateSetConfig = () => {
52
103
  const configPath = (0, projectFiles_1.getUpdateSetsConfigPath)();
53
104
  try {
@@ -155,6 +206,142 @@ async function resolveScope(args) {
155
206
  ]);
156
207
  return scopeAnswer.scope;
157
208
  }
209
+ /**
210
+ * Normalizes a Table API reference field — a bare sys_id string, or the
211
+ * `{ value, link }` object the Table API returns without display values —
212
+ * to its sys_id. Returns undefined for anything else (missing, empty, wrong
213
+ * shape) so callers never compare against garbage.
214
+ */
215
+ function referenceSysId(value) {
216
+ if (typeof value === "string") {
217
+ return value.trim() === "" ? undefined : value.trim();
218
+ }
219
+ if (value !== null && typeof value === "object") {
220
+ var inner = value.value;
221
+ if (typeof inner === "string" && inner.trim() !== "") {
222
+ return inner.trim();
223
+ }
224
+ }
225
+ return undefined;
226
+ }
227
+ /**
228
+ * Reads the created set back and returns its `application` sys_id. Falls
229
+ * back to the create response when the read-back fails or carries no
230
+ * application. Returns undefined when neither source can prove the scope.
231
+ */
232
+ async function readBackApplication(client, updateSetSysId, createResponseApplication) {
233
+ try {
234
+ var rows = await (0, snClient_1.unwrapSNResponse)(client.getUpdateSetById(updateSetSysId));
235
+ if (Array.isArray(rows) && rows.length > 0) {
236
+ var fromReadBack = referenceSysId(rows[0].application);
237
+ if (fromReadBack) {
238
+ return fromReadBack;
239
+ }
240
+ }
241
+ FileLogger_1.fileLogger.warn("Update set read-back returned no application for " + updateSetSysId);
242
+ }
243
+ catch (readErr) {
244
+ FileLogger_1.fileLogger.warn("Update set read-back failed for " +
245
+ updateSetSysId +
246
+ ": " +
247
+ (readErr instanceof Error ? readErr.message : String(readErr)));
248
+ }
249
+ return referenceSysId(createResponseApplication);
250
+ }
251
+ /**
252
+ * Resolves a scope sys_id back to its name for error messages. Never throws —
253
+ * falls back to the sys_id itself so a lookup failure can't mask the
254
+ * mismatch we are about to report.
255
+ */
256
+ async function scopeNameForSysId(client, scopeSysId) {
257
+ try {
258
+ var rows = await (0, snClient_1.unwrapSNResponse)(client.getScopeById(scopeSysId));
259
+ if (Array.isArray(rows) && rows.length > 0 && rows[0].scope) {
260
+ return rows[0].scope;
261
+ }
262
+ }
263
+ catch (e) {
264
+ // fall through to the sys_id
265
+ }
266
+ return scopeSysId;
267
+ }
268
+ /**
269
+ * Creates a `sys_update_set` through the scope-correct `createUpdateSet`
270
+ * server op and PROVES it landed in the requested scope. Throws on a
271
+ * mis-scoped or unverifiable set — the caller must never report success.
272
+ */
273
+ async function createUpdateSetRecord(client, scope, fields) {
274
+ if (typeof scope !== "string" || !SCOPE_NAME_PATTERN.test(scope)) {
275
+ throw new Error("Invalid scope name " +
276
+ JSON.stringify(scope) +
277
+ ": expected an application scope such as x_cadso_core.");
278
+ }
279
+ var name = typeof fields.name === "string" ? fields.name.trim() : "";
280
+ if (name === "") {
281
+ throw new Error("Update set name is required.");
282
+ }
283
+ var description = typeof fields.description === "string" && fields.description.trim() !== ""
284
+ ? fields.description
285
+ : undefined;
286
+ var unsupported = Object.keys(fields).filter(function (key) {
287
+ return UPDATE_SET_OP_FIELDS.indexOf(key) === -1;
288
+ });
289
+ if (unsupported.length > 0) {
290
+ Logger_1.logger.warn("sys_update_set is created through the scope-correct createUpdateSet " +
291
+ "op, which accepts only " +
292
+ UPDATE_SET_OP_FIELDS.join(", ") +
293
+ ". Ignoring field(s): " +
294
+ unsupported.join(", "));
295
+ }
296
+ // Resolve the scope name to the application sys_id the op needs.
297
+ var scopeRows = await (0, snClient_1.unwrapSNResponse)(client.getScopeId(scope));
298
+ if (!Array.isArray(scopeRows) || scopeRows.length === 0) {
299
+ throw new Error('Scope "' + scope + '" not found on the instance.');
300
+ }
301
+ var scopeSysId = scopeRows[0].sys_id;
302
+ if (typeof scopeSysId !== "string" || scopeSysId.trim() === "") {
303
+ throw new Error('Scope "' + scope + '" resolved without a sys_id.');
304
+ }
305
+ FileLogger_1.fileLogger.debug("createUpdateSet op:", JSON.stringify({ name: name, scope: scope, application: scopeSysId }));
306
+ var created = await (0, snClient_1.unwrapSNResponse)(client.createUpdateSet(name, scopeSysId, description));
307
+ var createdSysId = created && typeof created.sys_id === "string" ? created.sys_id : "";
308
+ if (createdSysId === "") {
309
+ throw new Error("createUpdateSet returned no sys_id for " + name);
310
+ }
311
+ // Verify on the way out: the whole point of this path is that the set is
312
+ // in the requested scope, so prove it rather than trust the op.
313
+ var actualScopeSysId = await readBackApplication(client, createdSysId, created.application);
314
+ if (!actualScopeSysId) {
315
+ throw new Error('Update set "' +
316
+ name +
317
+ '" (' +
318
+ createdSysId +
319
+ ") was created but its application scope could not be verified " +
320
+ "(requested " +
321
+ scope +
322
+ " / " +
323
+ scopeSysId +
324
+ "). Inspect the set on the instance before using it.");
325
+ }
326
+ if (actualScopeSysId !== scopeSysId) {
327
+ var actualScope = await scopeNameForSysId(client, actualScopeSysId);
328
+ throw new UpdateSetScopeMismatchError({
329
+ updateSetName: name,
330
+ updateSetSysId: createdSysId,
331
+ requestedScope: scope,
332
+ requestedScopeSysId: scopeSysId,
333
+ actualScope: actualScope,
334
+ actualScopeSysId: actualScopeSysId,
335
+ });
336
+ }
337
+ var createdName = created.name;
338
+ return {
339
+ sysId: createdSysId,
340
+ name: typeof createdName === "string" && createdName !== "" ? createdName : name,
341
+ scope: scope,
342
+ scopeSysId: scopeSysId,
343
+ };
344
+ }
158
345
  /**
159
346
  * Main create record command handler.
160
347
  */
@@ -167,7 +354,16 @@ async function createRecordCommand(args) {
167
354
  Logger_1.logger.error("Table name is required");
168
355
  process.exit(1);
169
356
  }
357
+ var isUpdateSet = table === exports.UPDATE_SET_TABLE;
170
358
  FileLogger_1.fileLogger.debug("Create record: table=" + table);
359
+ // An update set's application is decided by --scope alone. In CI there is
360
+ // no human to confirm a manifest-inferred scope, so require it explicitly
361
+ // rather than let an inherited scope mis-route every later capture.
362
+ if (isUpdateSet && typedArgs.ci && !typedArgs.scope) {
363
+ Logger_1.logger.error("--scope is required when creating sys_update_set in --ci mode: " +
364
+ "the scope decides the update set's application.");
365
+ process.exit(1);
366
+ }
171
367
  // 1. Build field values from all sources
172
368
  var fields = {};
173
369
  // Load from JSON file if provided
@@ -209,13 +405,17 @@ async function createRecordCommand(args) {
209
405
  // 3. Resolve scope
210
406
  var scope = await resolveScope(typedArgs);
211
407
  Logger_1.logger.info("Scope: " + chalk_1.default.cyan(scope));
212
- // 4. Check update set configuration
213
- var updateSetConfig = getUpdateSetConfig();
214
- var updateSet = updateSetConfig[scope];
408
+ // 4. Check update set configuration. An update set is not itself captured
409
+ // in an update set, so skip the lookup for sys_update_set.
410
+ var updateSet;
215
411
  var updateSetSysId;
216
- if (updateSet) {
217
- Logger_1.logger.info("Update set: " + chalk_1.default.cyan(updateSet.name));
218
- updateSetSysId = updateSet.sys_id;
412
+ if (!isUpdateSet) {
413
+ var updateSetConfig = getUpdateSetConfig();
414
+ updateSet = updateSetConfig[scope];
415
+ if (updateSet) {
416
+ Logger_1.logger.info("Update set: " + chalk_1.default.cyan(updateSet.name));
417
+ updateSetSysId = updateSet.sys_id;
418
+ }
219
419
  }
220
420
  // 5. Confirmation prompt
221
421
  if (!typedArgs.ci) {
@@ -224,6 +424,9 @@ async function createRecordCommand(args) {
224
424
  Logger_1.logger.info(" Table: " + chalk_1.default.cyan(table));
225
425
  Logger_1.logger.info(" Name: " + chalk_1.default.cyan(fields.name));
226
426
  Logger_1.logger.info(" Scope: " + chalk_1.default.cyan(scope));
427
+ if (isUpdateSet) {
428
+ Logger_1.logger.info(" Via: Dovetail createUpdateSet op (application = scope above)");
429
+ }
227
430
  if (updateSet) {
228
431
  Logger_1.logger.info(" Update Set: " + chalk_1.default.cyan(updateSet.name));
229
432
  }
@@ -249,7 +452,29 @@ async function createRecordCommand(args) {
249
452
  return;
250
453
  }
251
454
  }
252
- // 6. Call the create endpoint
455
+ var client = (0, snClient_1.defaultClient)();
456
+ // 6a. sys_update_set: the scope-correct server op, verified on the way out.
457
+ if (isUpdateSet) {
458
+ Logger_1.logger.info("Creating update set via the Dovetail createUpdateSet op...");
459
+ var createdSet = await createUpdateSetRecord(client, scope, fields);
460
+ Logger_1.logger.success(chalk_1.default.green("Update set created: ") +
461
+ chalk_1.default.bold(createdSet.name) +
462
+ " (" +
463
+ createdSet.sysId +
464
+ ") in scope " +
465
+ chalk_1.default.cyan(createdSet.scope));
466
+ Logger_1.logger.info("Verified application = " +
467
+ createdSet.scope +
468
+ " (" +
469
+ createdSet.scopeSysId +
470
+ ").");
471
+ Logger_1.logger.info("Not activated. To route pushes to it: npx dove switchUpdateSet --name " +
472
+ JSON.stringify(createdSet.name) +
473
+ " -s " +
474
+ createdSet.scope);
475
+ return;
476
+ }
477
+ // 6b. Every other table: the generic create endpoint
253
478
  Logger_1.logger.info("Creating record...");
254
479
  FileLogger_1.fileLogger.debug("Create request:", JSON.stringify({
255
480
  table: table,
@@ -257,7 +482,6 @@ async function createRecordCommand(args) {
257
482
  scope: scope,
258
483
  update_set_sys_id: updateSetSysId,
259
484
  }));
260
- var client = (0, snClient_1.defaultClient)();
261
485
  var createResponse = await client.createRecord({
262
486
  table: table,
263
487
  fields: fields,
package/dist/snClient.js CHANGED
@@ -352,6 +352,20 @@ const snClient = (baseURL, username, password, apiKey) => {
352
352
  },
353
353
  });
354
354
  };
355
+ /**
356
+ * Reads one update set back by sys_id, selecting only `application`, so a
357
+ * caller can PROVE which application a freshly created set landed in. The
358
+ * Table API returns the reference as `{ value, link }` (no display value).
359
+ */
360
+ const getUpdateSetById = (updateSetSysId) => {
361
+ const endpoint = "api/now/table/sys_update_set";
362
+ return client.get(endpoint, {
363
+ params: {
364
+ sysparm_query: `sys_id=${updateSetSysId}`,
365
+ sysparm_fields: "sys_id,name,application",
366
+ },
367
+ });
368
+ };
355
369
  const getUserSysId = (userName = process.env.SN_USER) => {
356
370
  const endpoint = "api/now/table/sys_user";
357
371
  return client.get(endpoint, {
@@ -523,6 +537,7 @@ const snClient = (baseURL, username, password, apiKey) => {
523
537
  updateRecord,
524
538
  getScopeId,
525
539
  getScopeById,
540
+ getUpdateSetById,
526
541
  getUserSysId,
527
542
  getCurrentAppUserPrefSysId,
528
543
  updateCurrentAppUserPref,
@@ -0,0 +1,276 @@
1
+ "use strict";
2
+ // Tests for `dove create sys_update_set` (TenonHQ/Dovetail#231).
3
+ //
4
+ // A generic record insert into sys_update_set lets ServiceNow default
5
+ // `application` to the API session's current app, so `--scope` was silently
6
+ // ignored. `dove create` must instead:
7
+ // 1. route sys_update_set through snClient.createUpdateSet with the resolved
8
+ // scope's sys_id — and NOT call the generic createRecord;
9
+ // 2. read the set back and exit non-zero, naming requested vs. actual scope
10
+ // and the set sys_id, when the application does not match;
11
+ // 3. require --scope for sys_update_set in --ci mode;
12
+ // 4. leave every other table on the generic createRecord path.
13
+ //
14
+ // Everything is mocked — no instance is contacted.
15
+ Object.defineProperty(exports, "__esModule", { value: true });
16
+ var logMessages = [];
17
+ function pushLog(level) {
18
+ return jest.fn(function (msg) {
19
+ logMessages.push({ level: level, msg: String(msg) });
20
+ });
21
+ }
22
+ jest.mock("../Logger", function () {
23
+ return {
24
+ logger: {
25
+ setLogLevel: jest.fn(),
26
+ success: pushLog("success"),
27
+ info: pushLog("info"),
28
+ error: pushLog("error"),
29
+ warn: pushLog("warn"),
30
+ debug: jest.fn(),
31
+ getLogLevel: function () { return "info"; },
32
+ },
33
+ };
34
+ });
35
+ jest.mock("../FileLogger", function () {
36
+ return {
37
+ fileLogger: { debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn() },
38
+ };
39
+ });
40
+ jest.mock("../commands", function () {
41
+ return { setLogLevel: jest.fn() };
42
+ });
43
+ jest.mock("../config", function () {
44
+ return {
45
+ getManifest: jest.fn(function () { return undefined; }),
46
+ isMultiScopeManifest: jest.fn(function () { return false; }),
47
+ getConfig: jest.fn(function () { return {}; }),
48
+ getSourcePathForScope: jest.fn(function () { return "/virtual/src"; }),
49
+ };
50
+ });
51
+ var mockSyncManifest = jest.fn().mockResolvedValue(undefined);
52
+ jest.mock("../appUtils", function () {
53
+ return { syncManifest: mockSyncManifest };
54
+ });
55
+ jest.mock("../projectFiles", function () {
56
+ return {
57
+ getUpdateSetsConfigPath: jest.fn(function () {
58
+ return "/nonexistent/dove-create-update-set-test/.dove-update-sets.json";
59
+ }),
60
+ };
61
+ });
62
+ jest.mock("inquirer", function () {
63
+ return { prompt: jest.fn() };
64
+ });
65
+ var mockClient = {
66
+ getScopeId: jest.fn(),
67
+ getScopeById: jest.fn(),
68
+ getUpdateSetById: jest.fn(),
69
+ createUpdateSet: jest.fn(),
70
+ createRecord: jest.fn(),
71
+ };
72
+ jest.mock("../snClient", function () {
73
+ return {
74
+ defaultClient: jest.fn(function () { return mockClient; }),
75
+ // Mirror the real unwrapSNResponse: await the axios promise, return .data.result
76
+ unwrapSNResponse: jest.fn(async function (p) {
77
+ var resp = await p;
78
+ return resp.data.result;
79
+ }),
80
+ };
81
+ });
82
+ const createRecordCommand_1 = require("../createRecordCommand");
83
+ function axiosResult(result) {
84
+ return Promise.resolve({ status: 200, data: { result: result } });
85
+ }
86
+ var REQUESTED_SCOPE = "x_cadso_journey";
87
+ var REQUESTED_SCOPE_SYS_ID = "journeyScopeSysId0000000000000001";
88
+ var SESSION_SCOPE = "x_cadso_core";
89
+ var SESSION_SCOPE_SYS_ID = "coreScopeSysId00000000000000000002";
90
+ var SET_SYS_ID = "updateSetSysId000000000000000000ab";
91
+ class ExitSignal extends Error {
92
+ code;
93
+ constructor(code) {
94
+ super("process.exit(" + code + ")");
95
+ this.code = code;
96
+ }
97
+ }
98
+ describe("dove create sys_update_set (#231)", function () {
99
+ var exitSpy;
100
+ beforeEach(function () {
101
+ logMessages = [];
102
+ jest.clearAllMocks();
103
+ exitSpy = jest
104
+ .spyOn(process, "exit")
105
+ .mockImplementation(function (code) {
106
+ throw new ExitSignal(typeof code === "number" ? code : undefined);
107
+ });
108
+ mockClient.getScopeId.mockImplementation(function (scopeName) {
109
+ if (scopeName === REQUESTED_SCOPE) {
110
+ return axiosResult([{ sys_id: REQUESTED_SCOPE_SYS_ID }]);
111
+ }
112
+ return axiosResult([]);
113
+ });
114
+ mockClient.getScopeById.mockImplementation(function (sysId) {
115
+ if (sysId === SESSION_SCOPE_SYS_ID) {
116
+ return axiosResult([{ sys_id: SESSION_SCOPE_SYS_ID, scope: SESSION_SCOPE }]);
117
+ }
118
+ if (sysId === REQUESTED_SCOPE_SYS_ID) {
119
+ return axiosResult([{ sys_id: REQUESTED_SCOPE_SYS_ID, scope: REQUESTED_SCOPE }]);
120
+ }
121
+ return axiosResult([]);
122
+ });
123
+ mockClient.createUpdateSet.mockImplementation(function (name, scopeSysId) {
124
+ return axiosResult({ sys_id: SET_SYS_ID, name: name, application: scopeSysId });
125
+ });
126
+ mockClient.createRecord.mockResolvedValue({
127
+ status: 200,
128
+ data: { result: { sys_id: "genericSysId", table: "sys_script_include", name: "Generic" } },
129
+ });
130
+ });
131
+ afterEach(function () {
132
+ exitSpy.mockRestore();
133
+ });
134
+ function errorText() {
135
+ return logMessages
136
+ .filter(function (m) { return m.level === "error"; })
137
+ .map(function (m) { return m.msg; })
138
+ .join("\n");
139
+ }
140
+ it("routes through createUpdateSet with the requested scope and never calls the generic insert", async function () {
141
+ // Table API shape for a reference field without display values: { value, link }
142
+ mockClient.getUpdateSetById.mockReturnValue(axiosResult([{ sys_id: SET_SYS_ID, application: { value: REQUESTED_SCOPE_SYS_ID, link: "ref-link" } }]));
143
+ await (0, createRecordCommand_1.createRecordCommand)({
144
+ table: "sys_update_set",
145
+ name: "Journey - Fix 231",
146
+ scope: REQUESTED_SCOPE,
147
+ field: ["description=Issue 231 regression set"],
148
+ ci: true,
149
+ logLevel: "info",
150
+ });
151
+ expect(mockClient.createUpdateSet).toHaveBeenCalledTimes(1);
152
+ expect(mockClient.createUpdateSet).toHaveBeenCalledWith("Journey - Fix 231", REQUESTED_SCOPE_SYS_ID, "Issue 231 regression set");
153
+ expect(mockClient.createRecord).not.toHaveBeenCalled();
154
+ expect(mockClient.getUpdateSetById).toHaveBeenCalledWith(SET_SYS_ID);
155
+ expect(mockSyncManifest).not.toHaveBeenCalled();
156
+ expect(exitSpy).not.toHaveBeenCalled();
157
+ var success = logMessages.filter(function (m) { return m.level === "success"; });
158
+ expect(success.length).toBe(1);
159
+ expect(success[0].msg).toContain(SET_SYS_ID);
160
+ expect(success[0].msg).toContain(REQUESTED_SCOPE);
161
+ });
162
+ it("fails loudly (exit 1, no success) when the created set's application is not the requested scope", async function () {
163
+ // The op "succeeded" but the set landed in the session's current app.
164
+ mockClient.createUpdateSet.mockImplementation(function (name) {
165
+ return axiosResult({ sys_id: SET_SYS_ID, name: name, application: SESSION_SCOPE_SYS_ID });
166
+ });
167
+ mockClient.getUpdateSetById.mockReturnValue(axiosResult([{ sys_id: SET_SYS_ID, application: { value: SESSION_SCOPE_SYS_ID } }]));
168
+ await expect((0, createRecordCommand_1.createRecordCommand)({
169
+ table: "sys_update_set",
170
+ name: "Journey - Fix 231",
171
+ scope: REQUESTED_SCOPE,
172
+ ci: true,
173
+ logLevel: "info",
174
+ })).rejects.toBeInstanceOf(ExitSignal);
175
+ expect(exitSpy).toHaveBeenCalledWith(1);
176
+ expect(mockClient.createRecord).not.toHaveBeenCalled();
177
+ expect(logMessages.filter(function (m) { return m.level === "success"; })).toHaveLength(0);
178
+ var errors = errorText();
179
+ expect(errors).toContain(SET_SYS_ID);
180
+ expect(errors).toContain(REQUESTED_SCOPE);
181
+ expect(errors).toContain(SESSION_SCOPE);
182
+ expect(errors).toContain(REQUESTED_SCOPE_SYS_ID);
183
+ expect(errors).toContain(SESSION_SCOPE_SYS_ID);
184
+ });
185
+ it("falls back to the op response when read-back is unavailable, and still catches a mismatch", async function () {
186
+ mockClient.createUpdateSet.mockImplementation(function (name) {
187
+ return axiosResult({ sys_id: SET_SYS_ID, name: name, application: SESSION_SCOPE_SYS_ID });
188
+ });
189
+ mockClient.getUpdateSetById.mockReturnValue(Promise.reject(new Error("read denied")));
190
+ await expect((0, createRecordCommand_1.createRecordCommand)({
191
+ table: "sys_update_set",
192
+ name: "Journey - Fix 231",
193
+ scope: REQUESTED_SCOPE,
194
+ ci: true,
195
+ logLevel: "info",
196
+ })).rejects.toBeInstanceOf(ExitSignal);
197
+ expect(exitSpy).toHaveBeenCalledWith(1);
198
+ expect(errorText()).toContain(SESSION_SCOPE);
199
+ expect(errorText()).toContain(SET_SYS_ID);
200
+ });
201
+ it("refuses to verify-by-assumption: no application from any source is a failure, not a success", async function () {
202
+ mockClient.createUpdateSet.mockImplementation(function (name) {
203
+ return axiosResult({ sys_id: SET_SYS_ID, name: name });
204
+ });
205
+ mockClient.getUpdateSetById.mockReturnValue(axiosResult([{ sys_id: SET_SYS_ID }]));
206
+ await expect((0, createRecordCommand_1.createRecordCommand)({
207
+ table: "sys_update_set",
208
+ name: "Journey - Fix 231",
209
+ scope: REQUESTED_SCOPE,
210
+ ci: true,
211
+ logLevel: "info",
212
+ })).rejects.toBeInstanceOf(ExitSignal);
213
+ expect(exitSpy).toHaveBeenCalledWith(1);
214
+ expect(errorText()).toContain("could not be verified");
215
+ expect(logMessages.filter(function (m) { return m.level === "success"; })).toHaveLength(0);
216
+ });
217
+ it("requires --scope for sys_update_set in --ci mode before touching the instance", async function () {
218
+ await expect((0, createRecordCommand_1.createRecordCommand)({
219
+ table: "sys_update_set",
220
+ name: "No Scope Set",
221
+ ci: true,
222
+ logLevel: "info",
223
+ })).rejects.toBeInstanceOf(ExitSignal);
224
+ expect(exitSpy).toHaveBeenCalledWith(1);
225
+ expect(mockClient.createUpdateSet).not.toHaveBeenCalled();
226
+ expect(mockClient.createRecord).not.toHaveBeenCalled();
227
+ expect(mockClient.getScopeId).not.toHaveBeenCalled();
228
+ expect(errorText()).toContain("--scope");
229
+ });
230
+ it("errors when the requested scope does not exist on the instance", async function () {
231
+ await expect((0, createRecordCommand_1.createRecordCommand)({
232
+ table: "sys_update_set",
233
+ name: "Ghost Scope Set",
234
+ scope: "x_cadso_nope",
235
+ ci: true,
236
+ logLevel: "info",
237
+ })).rejects.toBeInstanceOf(ExitSignal);
238
+ expect(mockClient.createUpdateSet).not.toHaveBeenCalled();
239
+ expect(errorText()).toContain("x_cadso_nope");
240
+ });
241
+ it("keeps every other table on the generic createRecord path", async function () {
242
+ await (0, createRecordCommand_1.createRecordCommand)({
243
+ table: "sys_script_include",
244
+ name: "Generic",
245
+ scope: REQUESTED_SCOPE,
246
+ ci: true,
247
+ refresh: false,
248
+ logLevel: "info",
249
+ });
250
+ expect(mockClient.createRecord).toHaveBeenCalledTimes(1);
251
+ expect(mockClient.createRecord).toHaveBeenCalledWith({
252
+ table: "sys_script_include",
253
+ fields: { name: "Generic" },
254
+ scope: REQUESTED_SCOPE,
255
+ update_set_sys_id: undefined,
256
+ });
257
+ expect(mockClient.createUpdateSet).not.toHaveBeenCalled();
258
+ expect(mockClient.getScopeId).not.toHaveBeenCalled();
259
+ expect(exitSpy).not.toHaveBeenCalled();
260
+ });
261
+ it("exposes a typed mismatch error carrying both scopes", function () {
262
+ var err = new createRecordCommand_1.UpdateSetScopeMismatchError({
263
+ updateSetName: "n",
264
+ updateSetSysId: "s",
265
+ requestedScope: REQUESTED_SCOPE,
266
+ requestedScopeSysId: REQUESTED_SCOPE_SYS_ID,
267
+ actualScope: SESSION_SCOPE,
268
+ actualScopeSysId: SESSION_SCOPE_SYS_ID,
269
+ });
270
+ expect(err).toBeInstanceOf(Error);
271
+ expect(err.name).toBe("UpdateSetScopeMismatchError");
272
+ expect(err.requestedScope).toBe(REQUESTED_SCOPE);
273
+ expect(err.actualScope).toBe(SESSION_SCOPE);
274
+ expect(err.message).toContain("MIS-SCOPED");
275
+ });
276
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tenonhq/dovetail-core",
3
- "version": "0.0.122",
3
+ "version": "0.0.123",
4
4
  "description": "Next-gen file syncer",
5
5
  "license": "GPL-3.0",
6
6
  "main": "./dist/index.js",