@tenonhq/dovetail-core 0.0.112 → 0.0.113

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,232 @@
1
+ "use strict";
2
+ /**
3
+ * Tests the SERVER half of the sync engine — servicenow/dovetail/sys_script_include/
4
+ * DovetailUtilsMS.js — against the record-name collision that silently dropped records.
5
+ *
6
+ * Before the fix, `records` was keyed by display name, so two records sharing a name
7
+ * collapsed into one entry: one vanished from the manifest with no warning, and because
8
+ * the survivor kept the shared folder, a later push to that folder wrote to the WRONG
9
+ * record.
10
+ *
11
+ * This loads the real script include into a sandbox with minimal ServiceNow stubs
12
+ * (Class / GlideRecord / gs) rather than re-implementing the logic, so the test fails if
13
+ * the deployed source regresses. There is no ATF/SN harness in this repo, and this code
14
+ * is deployed to a shared instance — so this is the only gate it gets before deploy.
15
+ *
16
+ * getFileMap() short-circuits when `includes[table]` carries explicit field overrides,
17
+ * which is why no sys_dictionary / TableUtils stub is needed here.
18
+ */
19
+ var __importDefault = (this && this.__importDefault) || function (mod) {
20
+ return (mod && mod.__esModule) ? mod : { "default": mod };
21
+ };
22
+ Object.defineProperty(exports, "__esModule", { value: true });
23
+ const fs_1 = __importDefault(require("fs"));
24
+ const path_1 = __importDefault(require("path"));
25
+ const vm_1 = __importDefault(require("vm"));
26
+ const SOURCE = path_1.default.join(__dirname, "../../../../servicenow/dovetail/sys_script_include/DovetailUtilsMS.js");
27
+ /** Minimal GlideRecord over a fixture list. Only what buildTableMap and processMissingFiles touch. */
28
+ function makeGlideRecord(rows) {
29
+ return function GlideRecordStub(_table) {
30
+ var i = -1;
31
+ this.get = function (sysId) {
32
+ i = rows.findIndex(function (r) {
33
+ return r.sys_id === sysId;
34
+ });
35
+ return i !== -1;
36
+ };
37
+ this.addQuery = function () {
38
+ return { addOrCondition: function () { } };
39
+ };
40
+ this.addEncodedQuery = function () { };
41
+ this.query = function () {
42
+ i = -1;
43
+ };
44
+ this.next = function () {
45
+ i += 1;
46
+ return i < rows.length;
47
+ };
48
+ this.getValue = function (field) {
49
+ return rows[i][field];
50
+ };
51
+ this.getDisplayValue = function (field) {
52
+ // No arg → the record's display value (the name). This is what
53
+ // generateRecordName() uses, and it is where the collision comes from.
54
+ return field ? rows[i][field] : rows[i].name;
55
+ };
56
+ this.getElements = function () {
57
+ return null; // metaData capture is skipped; getContents is false below
58
+ };
59
+ };
60
+ }
61
+ function loadUtils(rows) {
62
+ const warnings = [];
63
+ const sandbox = {
64
+ Class: {
65
+ create: function () {
66
+ return function () {
67
+ if (this.initialize)
68
+ this.initialize.apply(this, arguments);
69
+ };
70
+ },
71
+ },
72
+ GlideRecord: makeGlideRecord(rows),
73
+ gs: {
74
+ warn: function (msg) {
75
+ warnings.push(msg);
76
+ },
77
+ getProperty: function () {
78
+ return "https://example.service-now.com/";
79
+ },
80
+ },
81
+ };
82
+ vm_1.default.createContext(sandbox);
83
+ vm_1.default.runInContext(fs_1.default.readFileSync(SOURCE, "utf8"), sandbox);
84
+ return { utils: new sandbox.DovetailUtilsMS(), warnings: warnings };
85
+ }
86
+ function buildMap(rows) {
87
+ const loaded = loadUtils(rows);
88
+ const result = loaded.utils.buildTableMap({
89
+ tableName: "x_cadso_journey_action",
90
+ scopeId: "scope1",
91
+ getContents: false,
92
+ // Explicit field override → getFileMap short-circuits (no sys_dictionary stub).
93
+ includes: { x_cadso_journey_action: { script: { type: "js" } } },
94
+ excludes: {},
95
+ tableOptions: {},
96
+ });
97
+ return { records: result.records, warnings: loaded.warnings };
98
+ }
99
+ describe("DovetailUtilsMS.buildTableMap — duplicate display names", () => {
100
+ it("keeps BOTH records when two share a display name (regression: one was silently dropped)", () => {
101
+ const out = buildMap([
102
+ { sys_id: "1607d7f0c373f2d0d4ddf1db05013101", name: "Blueprint" },
103
+ { sys_id: "46ae2decc33f32d0d4ddf1db050131d9", name: "Blueprint" },
104
+ ]);
105
+ const keys = Object.keys(out.records);
106
+ expect(keys).toHaveLength(2); // was 1 — the second overwrote the first
107
+ const sysIds = keys.map((k) => out.records[k].sys_id).sort();
108
+ expect(sysIds).toEqual([
109
+ "1607d7f0c373f2d0d4ddf1db05013101",
110
+ "46ae2decc33f32d0d4ddf1db050131d9",
111
+ ]);
112
+ });
113
+ it("suffixes EVERY member of a colliding set, so no record arbitrarily keeps the bare name", () => {
114
+ // If only the later duplicate were suffixed, which record kept "Blueprint" would
115
+ // depend on query order — and folder names would churn between refreshes.
116
+ const out = buildMap([
117
+ { sys_id: "1607d7f0c373f2d0d4ddf1db05013101", name: "Blueprint" },
118
+ { sys_id: "46ae2decc33f32d0d4ddf1db050131d9", name: "Blueprint" },
119
+ ]);
120
+ expect(Object.keys(out.records).sort()).toEqual([
121
+ "Blueprint (1607d7f0)",
122
+ "Blueprint (46ae2dec)",
123
+ ]);
124
+ expect(out.records["Blueprint"]).toBeUndefined();
125
+ });
126
+ it("keeps record.name identical to the map key (writers build the folder path from it)", () => {
127
+ const out = buildMap([
128
+ { sys_id: "1607d7f0c373f2d0d4ddf1db05013101", name: "Blueprint" },
129
+ { sys_id: "46ae2decc33f32d0d4ddf1db050131d9", name: "Blueprint" },
130
+ ]);
131
+ Object.keys(out.records).forEach((key) => {
132
+ expect(out.records[key].name).toBe(key);
133
+ });
134
+ });
135
+ it("warns on a collision instead of dropping silently", () => {
136
+ const out = buildMap([
137
+ { sys_id: "1607d7f0c373f2d0d4ddf1db05013101", name: "Blueprint" },
138
+ { sys_id: "46ae2decc33f32d0d4ddf1db050131d9", name: "Blueprint" },
139
+ ]);
140
+ expect(out.warnings).toHaveLength(2);
141
+ expect(out.warnings[0]).toContain("duplicate display name");
142
+ expect(out.warnings[0]).toContain("Blueprint");
143
+ });
144
+ it("leaves unique names completely untouched (no suffix, no warning)", () => {
145
+ const out = buildMap([
146
+ { sys_id: "dc2ef56133a0e2507b18bc534d5c7bf5", name: "Send Email" },
147
+ { sys_id: "0d4626f933e826507b18bc534d5c7b37", name: "Send Text" },
148
+ ]);
149
+ expect(Object.keys(out.records).sort()).toEqual(["Send Email", "Send Text"]);
150
+ expect(out.warnings).toHaveLength(0);
151
+ });
152
+ it("handles three-way collisions", () => {
153
+ const out = buildMap([
154
+ { sys_id: "aaaaaaaa11111111", name: "Dup" },
155
+ { sys_id: "bbbbbbbb22222222", name: "Dup" },
156
+ { sys_id: "cccccccc33333333", name: "Dup" },
157
+ ]);
158
+ expect(Object.keys(out.records).sort()).toEqual([
159
+ "Dup (aaaaaaaa)",
160
+ "Dup (bbbbbbbb)",
161
+ "Dup (cccccccc)",
162
+ ]);
163
+ });
164
+ it("disambiguates only the colliding set, not the whole table", () => {
165
+ const out = buildMap([
166
+ { sys_id: "aaaaaaaa11111111", name: "Blueprint" },
167
+ { sys_id: "bbbbbbbb22222222", name: "Blueprint" },
168
+ { sys_id: "cccccccc33333333", name: "Send Email" },
169
+ ]);
170
+ expect(Object.keys(out.records).sort()).toEqual([
171
+ "Blueprint (aaaaaaaa)",
172
+ "Blueprint (bbbbbbbb)",
173
+ "Send Email", // untouched — this is why differentiatorField was the wrong tool
174
+ ]);
175
+ });
176
+ });
177
+ /**
178
+ * bulkDownload keys its response by display name too — the same collapse dropped the
179
+ * file CONTENT for one of a colliding pair even once the manifest listed both.
180
+ */
181
+ function processMissing(rows) {
182
+ const loaded = loadUtils(rows);
183
+ const recordMap = {};
184
+ rows.forEach((r) => {
185
+ recordMap[r.sys_id] = [{ name: "script", type: "js" }];
186
+ });
187
+ const result = loaded.utils.processMissingFiles({ x_cadso_journey_action: recordMap }, {});
188
+ return {
189
+ records: result.x_cadso_journey_action.records,
190
+ warnings: loaded.warnings,
191
+ };
192
+ }
193
+ describe("DovetailUtilsMS.processMissingFiles — duplicate display names", () => {
194
+ it("keeps BOTH records' content when two share a display name (regression: one payload was silently dropped)", () => {
195
+ const out = processMissing([
196
+ { sys_id: "1607d7f0c373f2d0d4ddf1db05013101", name: "Blueprint", script: "var a = 1;" },
197
+ { sys_id: "46ae2decc33f32d0d4ddf1db050131d9", name: "Blueprint", script: "var b = 2;" },
198
+ ]);
199
+ const keys = Object.keys(out.records).sort();
200
+ expect(keys).toEqual(["Blueprint (1607d7f0)", "Blueprint (46ae2dec)"]);
201
+ expect(out.records["Blueprint"]).toBeUndefined();
202
+ // The actual bulkDownload payload — each record keeps its OWN file content.
203
+ expect(out.records["Blueprint (1607d7f0)"].files[0].content).toBe("var a = 1;");
204
+ expect(out.records["Blueprint (46ae2dec)"].files[0].content).toBe("var b = 2;");
205
+ });
206
+ it("keeps record.name identical to the map key (writers build the folder path from it)", () => {
207
+ const out = processMissing([
208
+ { sys_id: "1607d7f0c373f2d0d4ddf1db05013101", name: "Blueprint", script: "" },
209
+ { sys_id: "46ae2decc33f32d0d4ddf1db050131d9", name: "Blueprint", script: "" },
210
+ ]);
211
+ Object.keys(out.records).forEach((key) => {
212
+ expect(out.records[key].name).toBe(key);
213
+ });
214
+ });
215
+ it("warns on a collision instead of staying silent (parity with buildTableMap)", () => {
216
+ const out = processMissing([
217
+ { sys_id: "1607d7f0c373f2d0d4ddf1db05013101", name: "Blueprint", script: "" },
218
+ { sys_id: "46ae2decc33f32d0d4ddf1db050131d9", name: "Blueprint", script: "" },
219
+ ]);
220
+ expect(out.warnings).toHaveLength(2);
221
+ expect(out.warnings[0]).toContain("duplicate display name");
222
+ expect(out.warnings[0]).toContain("bulkDownload");
223
+ });
224
+ it("leaves unique names completely untouched (no suffix, no warning)", () => {
225
+ const out = processMissing([
226
+ { sys_id: "dc2ef56133a0e2507b18bc534d5c7bf5", name: "Send Email", script: "" },
227
+ { sys_id: "0d4626f933e826507b18bc534d5c7b37", name: "Send Text", script: "" },
228
+ ]);
229
+ expect(Object.keys(out.records).sort()).toEqual(["Send Email", "Send Text"]);
230
+ expect(out.warnings).toHaveLength(0);
231
+ });
232
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tenonhq/dovetail-core",
3
- "version": "0.0.112",
3
+ "version": "0.0.113",
4
4
  "description": "Next-gen file syncer",
5
5
  "license": "GPL-3.0",
6
6
  "main": "./dist/index.js",
@@ -1,7 +1,33 @@
1
1
  {
2
2
  "$schema": "dovetail-manifest/v1",
3
- "generated_at": "2026-07-14T18:00:31.248Z",
3
+ "generated_at": "2026-07-14T19:40:43.710Z",
4
4
  "events": [
5
+ {
6
+ "$schema": "dovetail-release-event/v1",
7
+ "event_id": "dovetail-core@0.0.112",
8
+ "package": "@tenonhq/dovetail-core",
9
+ "version": "0.0.112",
10
+ "prev_version": "0.0.111",
11
+ "published_at": "2026-07-14T19:40:43.702Z",
12
+ "git": {
13
+ "sha": "a2777edcbd46ee69de13e73173132f9cd18aa618",
14
+ "tag": "dovetail-core@0.0.112"
15
+ },
16
+ "semver_bump": "patch",
17
+ "commits": [
18
+ {
19
+ "sha": "a2777edcb",
20
+ "subject": "fix: reject unknown dove commands and alias pull to refresh (#219) (#221)",
21
+ "pr": 219
22
+ },
23
+ {
24
+ "sha": "6a42ef546",
25
+ "subject": "chore(release): @tenonhq/dovetail-core@0.0.111 [skip ci]"
26
+ }
27
+ ],
28
+ "exports_delta": null,
29
+ "narrative": null
30
+ },
5
31
  {
6
32
  "$schema": "dovetail-release-event/v1",
7
33
  "event_id": "dovetail-core@0.0.111",
@@ -1251,32 +1277,6 @@
1251
1277
  ],
1252
1278
  "exports_delta": null,
1253
1279
  "narrative": null
1254
- },
1255
- {
1256
- "$schema": "dovetail-release-event/v1",
1257
- "event_id": "dovetail-mcp@0.0.9",
1258
- "package": "@tenonhq/dovetail-mcp",
1259
- "version": "0.0.9",
1260
- "prev_version": "0.0.8",
1261
- "published_at": "2026-06-05T06:43:18.252Z",
1262
- "git": {
1263
- "sha": "71bdf9bba8bd5e3849a212a18d9ebc9241232b32",
1264
- "tag": "dovetail-mcp@0.0.9"
1265
- },
1266
- "semver_bump": "patch",
1267
- "commits": [
1268
- {
1269
- "sha": "71bdf9bba",
1270
- "subject": "fix: float internal dep ranges to ~0.0.x so consumers need no overrides (#148)",
1271
- "pr": 148
1272
- },
1273
- {
1274
- "sha": "af1417796",
1275
- "subject": "chore(release): publish 3 packages [skip ci]"
1276
- }
1277
- ],
1278
- "exports_delta": null,
1279
- "narrative": null
1280
1280
  }
1281
1281
  ]
1282
1282
  }