@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
- // Strip the derived `display_value` from every field pair, keeping only `value`.
64
- // A ServiceNow field is serialized as a { value, display_value } pair. The
65
- // display_value is the *current* display name of the field's value — for a
66
- // reference field, the referenced record's name, resolved live on the server at
67
- // pull time. That makes it non-deterministic on disk: rename a referenced record
68
- // on the instance and every metaData.json pointing at it re-churns its
69
- // display_value on the next pull, even though the pointing record is unchanged.
70
- // `value` (the sys_id / raw value) is the stable identity; display_value is a
71
- // convenience the mirror does not need. Reconcile already ignores metaData.json
72
- // content and pushes key off `value` / the manifest, so no consumer depends on
73
- // the persisted display_value. Dropping it keeps re-pulls byte-identical.
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
- delete field.display_value;
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("strips every display_value, keeping value", async function () {
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 raw = readRecordFile("metaData.json");
158
- var written = JSON.parse(raw);
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
- describe("stampMetadataContent — display_value stripping", () => {
122
- it("drops display_value from field pairs, keeping value", () => {
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
- const parsed = parse(out);
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("strips every field's display_value in one pass", () => {
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
- a: { value: "1", display_value: "One" },
139
- b: { value: "2", display_value: "Two" },
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.a).toEqual({ value: "1" });
143
- expect(parsed.b).toEqual({ value: "2" });
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 — a display-value-free record stays byte-identical", () => {
157
- const clean = {
158
- sys_updated_on: { value: "2025-08-21 17:09:02" },
159
- action: { value: "abc" },
160
- _record_link: "/incident.do?sys_id=1",
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(clean));
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
- expect(meta).not.toContain("display_value");
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tenonhq/dovetail-core",
3
- "version": "0.0.116",
3
+ "version": "0.0.117",
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-28T03:15:38.027Z",
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
  }