@tenonhq/dovetail-core 0.0.116 → 0.0.117
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/dist/appUtils.js
CHANGED
|
@@ -60,24 +60,65 @@ const getUpdateSetConfig = () => (0, updateSetConfig_1.readUpdateSetConfig)();
|
|
|
60
60
|
// (/<table>.do?sys_id=...) resolves against whatever instance the reader is on.
|
|
61
61
|
// Matches any *.service-now.com host so the output is portable across instances.
|
|
62
62
|
const stripRecordLinkHost = (link) => link.replace(/^https?:\/\/[^/]+\.service-now\.com/i, "");
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
//
|
|
63
|
+
// A ServiceNow field is serialized as a { value, display_value } pair, where
|
|
64
|
+
// display_value is rendered live server-side at pull time. Some of those
|
|
65
|
+
// renderings are worth persisting and some are pure churn, so this is a
|
|
66
|
+
// selective drop rather than a blanket one — an earlier blanket strip threw away
|
|
67
|
+
// the readable sys_id -> name mapping that `Documentation/Dovetail-Development.md`
|
|
68
|
+
// documents grepping to walk the record graph.
|
|
69
|
+
//
|
|
70
|
+
// Measured over 43,990 pairs in the servicenow-files mirror:
|
|
71
|
+
//
|
|
72
|
+
// DROPPED
|
|
73
|
+
// identical (62.1%, ~4.5 MB) — display_value === value carries zero
|
|
74
|
+
// information by construction, at any size. The heavy ones are the large
|
|
75
|
+
// text fields (script, props, label_cache, composition, operation_script):
|
|
76
|
+
// 619 pairs, but ~3.7 MB of the total.
|
|
77
|
+
// datetime (3.9%) — `value` is UTC; `display_value` is rendered in the
|
|
78
|
+
// TIMEZONE OF WHOEVER RAN THE PULL. Measured 1,706 shifted out of 1,706,
|
|
79
|
+
// zero exceptions. Same instance, same record, a colleague's laptop ->
|
|
80
|
+
// different bytes. Machine-dependent output cannot live in a shared repo.
|
|
81
|
+
// sys_scope / sys_package — present in ~0.95 records per file, so renaming
|
|
82
|
+
// the app once rewrites the entire mirror. The scope is already obvious
|
|
83
|
+
// from the folder path (src/Work/, src/Core/), so the value is low and
|
|
84
|
+
// the blast radius is the whole tree.
|
|
85
|
+
//
|
|
86
|
+
// KEPT
|
|
87
|
+
// boolean (21.1%) "0" -> "false", choice/class labels (6.5%) e.g.
|
|
88
|
+
// sys_class_name -> "Access Control", and every other reference (5.6%)
|
|
89
|
+
// sys_id -> record name. These are stable, small (~420 KB combined), and
|
|
90
|
+
// they are the reason a human can read the file at all. A reference
|
|
91
|
+
// display_value still churns if someone renames its target, but that is
|
|
92
|
+
// real instance state changing — not the puller's machine.
|
|
93
|
+
//
|
|
94
|
+
// No consumer reads display_value: reconcile ignores metaData.json content
|
|
95
|
+
// entirely and push keys off `value` / the manifest.
|
|
96
|
+
const SYS_DATETIME_RE = /^\d{4}-\d{2}-\d{2}( \d{2}:\d{2}:\d{2})?$/;
|
|
97
|
+
// References carried by nearly every record — one rename churns the whole tree.
|
|
98
|
+
const WHOLE_TREE_REFERENCE_KEYS = ["sys_scope", "sys_package"];
|
|
99
|
+
// Normalize for comparison: the server uses null and "" interchangeably across
|
|
100
|
+
// the two halves of a pair, and String(null) would otherwise read as "null".
|
|
101
|
+
const displayNorm = (v) => v === null || v === undefined ? "" : String(v);
|
|
102
|
+
const shouldDropDisplayValue = (key, value, displayValue) => {
|
|
103
|
+
if (displayNorm(value) === displayNorm(displayValue))
|
|
104
|
+
return true;
|
|
105
|
+
if (SYS_DATETIME_RE.test(displayNorm(value)))
|
|
106
|
+
return true;
|
|
107
|
+
if (WHOLE_TREE_REFERENCE_KEYS.indexOf(key) !== -1)
|
|
108
|
+
return true;
|
|
109
|
+
return false;
|
|
110
|
+
};
|
|
74
111
|
const stripFieldDisplayValues = (metadata) => {
|
|
75
112
|
for (const key of Object.keys(metadata)) {
|
|
76
113
|
const field = metadata[key];
|
|
77
114
|
if (field !== null &&
|
|
78
115
|
typeof field === "object" &&
|
|
116
|
+
!Array.isArray(field) &&
|
|
79
117
|
"display_value" in field) {
|
|
80
|
-
|
|
118
|
+
const pair = field;
|
|
119
|
+
if (shouldDropDisplayValue(key, pair.value, pair.display_value)) {
|
|
120
|
+
delete pair.display_value;
|
|
121
|
+
}
|
|
81
122
|
}
|
|
82
123
|
}
|
|
83
124
|
};
|
|
@@ -113,6 +113,11 @@ function serverMetadata() {
|
|
|
113
113
|
value: "4e4449a5475c255085d19fd8036d43a0",
|
|
114
114
|
display_value: "Tenon Marketing Work Management",
|
|
115
115
|
},
|
|
116
|
+
// A plain reference — kept, so the sys_id -> name mapping survives.
|
|
117
|
+
action: {
|
|
118
|
+
value: "85de623c33ef2a107b18bc534d5c7b92",
|
|
119
|
+
display_value: "Parse hashes for to_addresses",
|
|
120
|
+
},
|
|
116
121
|
_table: "sys_script_include",
|
|
117
122
|
_sys_id: SYS_ID,
|
|
118
123
|
_record_link: "https://tenonworkstudio.service-now.com/sys_script_include.do?sys_id=" + SYS_ID,
|
|
@@ -152,14 +157,18 @@ describe("all-scopes writer — metaData.json is normalized, not written raw", f
|
|
|
152
157
|
expect(written._record_link).toBe("/sys_script_include.do?sys_id=" + SYS_ID);
|
|
153
158
|
expect(readRecordFile("metaData.json")).not.toContain("service-now.com");
|
|
154
159
|
});
|
|
155
|
-
test("
|
|
160
|
+
test("applies the selective display_value policy, same as the refresh path", async function () {
|
|
156
161
|
await AllScopes.processManifestForScope(manifestWith([{ name: "metaData", type: "json", content: serverMetadata() }]), tmpRoot, true);
|
|
157
|
-
var
|
|
158
|
-
|
|
159
|
-
expect(raw).not.toContain("display_value");
|
|
162
|
+
var written = JSON.parse(readRecordFile("metaData.json"));
|
|
163
|
+
// Dropped: identical, datetime (puller's timezone), sys_scope (whole-tree).
|
|
160
164
|
expect(written.name).toEqual({ value: "UIFilterApiMS" });
|
|
161
165
|
expect(written.sys_updated_on).toEqual({ value: "2026-04-29 14:38:51" });
|
|
162
166
|
expect(written.sys_scope).toEqual({ value: "4e4449a5475c255085d19fd8036d43a0" });
|
|
167
|
+
// Kept: the readable reference name.
|
|
168
|
+
expect(written.action).toEqual({
|
|
169
|
+
value: "85de623c33ef2a107b18bc534d5c7b92",
|
|
170
|
+
display_value: "Parse hashes for to_addresses",
|
|
171
|
+
});
|
|
163
172
|
});
|
|
164
173
|
test("drops _lastUpdatedOn and writes no wall-clock value", async function () {
|
|
165
174
|
await AllScopes.processManifestForScope(manifestWith([{ name: "metaData", type: "json", content: serverMetadata() }]), tmpRoot, true);
|
|
@@ -118,30 +118,96 @@ describe("stampMetadataContent — no generation-time values", () => {
|
|
|
118
118
|
expect((0, appUtils_1.stampMetadataContent)(arr)).toEqual(arr);
|
|
119
119
|
});
|
|
120
120
|
});
|
|
121
|
-
|
|
122
|
-
|
|
121
|
+
// display_value handling is SELECTIVE, not a blanket strip. The rule was chosen
|
|
122
|
+
// from a measurement of 43,990 pairs in the real servicenow-files mirror; each
|
|
123
|
+
// test below names the category and why it falls on the side it does.
|
|
124
|
+
describe("stampMetadataContent — display_value DROPPED where it is churn", () => {
|
|
125
|
+
it("drops it when it is identical to value (zero information, any size)", () => {
|
|
126
|
+
const out = (0, appUtils_1.stampMetadataContent)(metaFile({
|
|
127
|
+
name: { value: "UIFilterApiMS", display_value: "UIFilterApiMS" },
|
|
128
|
+
}));
|
|
129
|
+
expect(parse(out).name).toEqual({ value: "UIFilterApiMS" });
|
|
130
|
+
});
|
|
131
|
+
it("drops identical pairs on large text fields too (script, composition)", () => {
|
|
132
|
+
// 619 pairs but ~3.7 MB of the corpus — the bulk of the file weight.
|
|
133
|
+
const big = "var x = 1;\n".repeat(60);
|
|
134
|
+
const out = (0, appUtils_1.stampMetadataContent)(metaFile({ script: { value: big, display_value: big } }));
|
|
135
|
+
expect(parse(out).script).toEqual({ value: big });
|
|
136
|
+
});
|
|
137
|
+
it("drops it on datetime fields — display_value is the PULLER's timezone", () => {
|
|
138
|
+
// Measured 1,706 TZ-shifted out of 1,706. `value` is UTC; display_value is
|
|
139
|
+
// rendered in the local timezone of whoever ran the pull, so keeping it
|
|
140
|
+
// makes the bytes depend on which laptop synced.
|
|
141
|
+
const out = (0, appUtils_1.stampMetadataContent)(metaFile({
|
|
142
|
+
sys_updated_on: { value: "2026-04-29 14:38:51", display_value: "2026-04-29 07:38:51" },
|
|
143
|
+
sys_created_on: { value: "2026-04-29 14:38:51", display_value: "2026-04-29 07:38:51" },
|
|
144
|
+
}));
|
|
145
|
+
const parsed = parse(out);
|
|
146
|
+
expect(parsed.sys_updated_on).toEqual({ value: "2026-04-29 14:38:51" });
|
|
147
|
+
expect(parsed.sys_created_on).toEqual({ value: "2026-04-29 14:38:51" });
|
|
148
|
+
});
|
|
149
|
+
it("drops it on a date-only value as well", () => {
|
|
150
|
+
const out = (0, appUtils_1.stampMetadataContent)(metaFile({ start_date: { value: "2026-04-29", display_value: "29-04-2026" } }));
|
|
151
|
+
expect(parse(out).start_date).toEqual({ value: "2026-04-29" });
|
|
152
|
+
});
|
|
153
|
+
it("drops it on sys_scope / sys_package — one app rename churns the whole tree", () => {
|
|
154
|
+
// Present in ~0.95 records per file. The scope is already obvious from the
|
|
155
|
+
// folder path (src/Work/, src/Core/), so the value is low and the blast
|
|
156
|
+
// radius is every file in the mirror.
|
|
157
|
+
const out = (0, appUtils_1.stampMetadataContent)(metaFile({
|
|
158
|
+
sys_scope: {
|
|
159
|
+
value: "4e4449a5475c255085d19fd8036d43a0",
|
|
160
|
+
display_value: "Tenon Marketing Work Management",
|
|
161
|
+
},
|
|
162
|
+
sys_package: {
|
|
163
|
+
value: "4e4449a5475c255085d19fd8036d43a0",
|
|
164
|
+
display_value: "Tenon Marketing Work Management",
|
|
165
|
+
},
|
|
166
|
+
}));
|
|
167
|
+
const parsed = parse(out);
|
|
168
|
+
expect(parsed.sys_scope).toEqual({ value: "4e4449a5475c255085d19fd8036d43a0" });
|
|
169
|
+
expect(parsed.sys_package).toEqual({ value: "4e4449a5475c255085d19fd8036d43a0" });
|
|
170
|
+
});
|
|
171
|
+
it("treats null and \"\" as identical rather than writing \"null\"", () => {
|
|
172
|
+
const out = (0, appUtils_1.stampMetadataContent)(metaFile({ controlled_by_refs: { value: null, display_value: "" } }));
|
|
173
|
+
expect(parse(out).controlled_by_refs).toEqual({ value: null });
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
describe("stampMetadataContent — display_value KEPT where it is information", () => {
|
|
177
|
+
it("keeps a reference's human name (the sys_id -> name mapping)", () => {
|
|
178
|
+
// This is the readable mapping Documentation/Dovetail-Development.md
|
|
179
|
+
// documents grepping to walk the record graph. A blanket strip destroyed it.
|
|
123
180
|
const out = (0, appUtils_1.stampMetadataContent)(metaFile({
|
|
124
|
-
sys_updated_on: { value: "2025-08-21 17:09:02" },
|
|
125
181
|
action: {
|
|
126
182
|
value: "85de623c33ef2a107b18bc534d5c7b92",
|
|
127
183
|
display_value: "Parse hashes for to_addresses",
|
|
128
184
|
},
|
|
129
185
|
}));
|
|
130
|
-
|
|
131
|
-
expect(parsed.action).toEqual({
|
|
186
|
+
expect(parse(out).action).toEqual({
|
|
132
187
|
value: "85de623c33ef2a107b18bc534d5c7b92",
|
|
188
|
+
display_value: "Parse hashes for to_addresses",
|
|
133
189
|
});
|
|
134
|
-
expect(parsed.action.display_value).toBeUndefined();
|
|
135
190
|
});
|
|
136
|
-
it("
|
|
191
|
+
it("keeps boolean renderings (0 -> false)", () => {
|
|
192
|
+
const out = (0, appUtils_1.stampMetadataContent)(metaFile({
|
|
193
|
+
active: { value: "1", display_value: "true" },
|
|
194
|
+
client_callable: { value: "0", display_value: "false" },
|
|
195
|
+
}));
|
|
196
|
+
const parsed = parse(out);
|
|
197
|
+
expect(parsed.active).toEqual({ value: "1", display_value: "true" });
|
|
198
|
+
expect(parsed.client_callable).toEqual({ value: "0", display_value: "false" });
|
|
199
|
+
});
|
|
200
|
+
it("keeps choice / class labels", () => {
|
|
137
201
|
const out = (0, appUtils_1.stampMetadataContent)(metaFile({
|
|
138
|
-
|
|
139
|
-
|
|
202
|
+
sys_class_name: { value: "sys_security_acl", display_value: "Access Control" },
|
|
203
|
+
access: { value: "package_private", display_value: "This application scope only" },
|
|
140
204
|
}));
|
|
141
205
|
const parsed = parse(out);
|
|
142
|
-
expect(parsed.
|
|
143
|
-
expect(parsed.
|
|
206
|
+
expect(parsed.sys_class_name.display_value).toBe("Access Control");
|
|
207
|
+
expect(parsed.access.display_value).toBe("This application scope only");
|
|
144
208
|
});
|
|
209
|
+
});
|
|
210
|
+
describe("stampMetadataContent — display_value determinism", () => {
|
|
145
211
|
it("leaves value-only fields and string keys untouched", () => {
|
|
146
212
|
const out = (0, appUtils_1.stampMetadataContent)(metaFile({
|
|
147
213
|
sys_updated_on: { value: "2025-08-21 17:09:02" },
|
|
@@ -153,14 +219,30 @@ describe("stampMetadataContent — display_value stripping", () => {
|
|
|
153
219
|
expect(parsed.name).toEqual({ value: "plain" });
|
|
154
220
|
expect(parsed._record_link).toBe("/incident.do?sys_id=1");
|
|
155
221
|
});
|
|
156
|
-
it("is idempotent
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
222
|
+
it("is idempotent across a full mixed record — kept pairs survive a re-stamp", () => {
|
|
223
|
+
// The failure mode this guards: a rule that keeps a pair on pass 1 but drops
|
|
224
|
+
// it on pass 2 would churn forever. Run a record carrying every category.
|
|
225
|
+
const mixed = {
|
|
226
|
+
sys_updated_on: { value: "2026-04-29 14:38:51", display_value: "2026-04-29 07:38:51" },
|
|
227
|
+
sys_scope: { value: "4e4449a5", display_value: "Tenon Marketing Work Management" },
|
|
228
|
+
sys_class_name: { value: "sys_security_acl", display_value: "Access Control" },
|
|
229
|
+
active: { value: "1", display_value: "true" },
|
|
230
|
+
action: { value: "85de623c", display_value: "Parse hashes" },
|
|
231
|
+
name: { value: "same", display_value: "same" },
|
|
232
|
+
_record_link: "https://tenonworkstudio.service-now.com/incident.do?sys_id=1",
|
|
161
233
|
};
|
|
162
|
-
const first = (0, appUtils_1.stampMetadataContent)(metaFile(
|
|
234
|
+
const first = (0, appUtils_1.stampMetadataContent)(metaFile(mixed));
|
|
163
235
|
const second = (0, appUtils_1.stampMetadataContent)(first);
|
|
236
|
+
const third = (0, appUtils_1.stampMetadataContent)(second);
|
|
164
237
|
expect(second.content).toBe(first.content);
|
|
238
|
+
expect(third.content).toBe(first.content);
|
|
239
|
+
// And the kept set is exactly what we intend.
|
|
240
|
+
const parsed = parse(first);
|
|
241
|
+
expect(parsed.sys_updated_on.display_value).toBeUndefined();
|
|
242
|
+
expect(parsed.sys_scope.display_value).toBeUndefined();
|
|
243
|
+
expect(parsed.name.display_value).toBeUndefined();
|
|
244
|
+
expect(parsed.sys_class_name.display_value).toBe("Access Control");
|
|
245
|
+
expect(parsed.active.display_value).toBe("true");
|
|
246
|
+
expect(parsed.action.display_value).toBe("Parse hashes");
|
|
165
247
|
});
|
|
166
248
|
});
|
|
@@ -334,7 +334,8 @@ describe("syncManifest — --metadata-only rewrites metadata and nothing else",
|
|
|
334
334
|
// Normalized on the way in — the whole reason to regenerate.
|
|
335
335
|
expect(parsed._record_link).toBe("/sys_script_include.do?sys_id=1");
|
|
336
336
|
expect(parsed._lastUpdatedOn).toBeUndefined();
|
|
337
|
-
|
|
337
|
+
// datetime display_value is the puller's local timezone — dropped.
|
|
338
|
+
expect(parsed.sys_updated_on).toEqual({ value: "2026-04-29 14:38:51" });
|
|
338
339
|
});
|
|
339
340
|
test("does not touch field files, even when the instance content differs", async function () {
|
|
340
341
|
writeLocal("Rec", "script", "js", "var local = 1;");
|
package/package.json
CHANGED
package/release-manifest.json
CHANGED
|
@@ -1,7 +1,33 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "dovetail-manifest/v1",
|
|
3
|
-
"generated_at": "2026-07-
|
|
3
|
+
"generated_at": "2026-07-28T06:17:01.139Z",
|
|
4
4
|
"events": [
|
|
5
|
+
{
|
|
6
|
+
"$schema": "dovetail-release-event/v1",
|
|
7
|
+
"event_id": "dovetail-core@0.0.116",
|
|
8
|
+
"package": "@tenonhq/dovetail-core",
|
|
9
|
+
"version": "0.0.116",
|
|
10
|
+
"prev_version": "0.0.115",
|
|
11
|
+
"published_at": "2026-07-28T06:17:01.130Z",
|
|
12
|
+
"git": {
|
|
13
|
+
"sha": "ab33183076fdb0eb18a8fc1c8e79b607af3893c4",
|
|
14
|
+
"tag": "dovetail-core@0.0.116"
|
|
15
|
+
},
|
|
16
|
+
"semver_bump": "patch",
|
|
17
|
+
"commits": [
|
|
18
|
+
{
|
|
19
|
+
"sha": "ab3318307",
|
|
20
|
+
"subject": "fix(core): --metadata-only must not write the scope manifest (#249)",
|
|
21
|
+
"pr": 249
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"sha": "f77cab2e5",
|
|
25
|
+
"subject": "chore(release): @tenonhq/dovetail-core@0.0.115 [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.115",
|
|
@@ -1228,32 +1254,6 @@
|
|
|
1228
1254
|
],
|
|
1229
1255
|
"exports_delta": null,
|
|
1230
1256
|
"narrative": null
|
|
1231
|
-
},
|
|
1232
|
-
{
|
|
1233
|
-
"$schema": "dovetail-release-event/v1",
|
|
1234
|
-
"event_id": "dovetail-mcp@0.0.16",
|
|
1235
|
-
"package": "@tenonhq/dovetail-mcp",
|
|
1236
|
-
"version": "0.0.16",
|
|
1237
|
-
"prev_version": "0.0.15",
|
|
1238
|
-
"published_at": "2026-06-15T17:59:30.698Z",
|
|
1239
|
-
"git": {
|
|
1240
|
-
"sha": "51e4e4592d491cd337f5bc1aa93d04f582f555a1",
|
|
1241
|
-
"tag": "dovetail-mcp@0.0.16"
|
|
1242
|
-
},
|
|
1243
|
-
"semver_bump": "patch",
|
|
1244
|
-
"commits": [
|
|
1245
|
-
{
|
|
1246
|
-
"sha": "51e4e4592",
|
|
1247
|
-
"subject": "feat(mcp): per-call instance retarget for servicenow_query_table (#180)",
|
|
1248
|
-
"pr": 180
|
|
1249
|
-
},
|
|
1250
|
-
{
|
|
1251
|
-
"sha": "c5d9106c4",
|
|
1252
|
-
"subject": "chore(release): publish 2 packages [skip ci]"
|
|
1253
|
-
}
|
|
1254
|
-
],
|
|
1255
|
-
"exports_delta": null,
|
|
1256
|
-
"narrative": null
|
|
1257
1257
|
}
|
|
1258
1258
|
]
|
|
1259
1259
|
}
|