@tenonhq/dovetail-core 0.0.114 → 0.0.115
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/allScopesCommands.js +15 -8
- package/dist/appUtils.js +119 -47
- package/dist/commander.js +7 -0
- package/dist/commands.js +9 -0
- package/dist/reconcile/baseline.js +3 -3
- package/dist/reconcile/fields.js +2 -2
- package/dist/reconcile/recordSource.js +6 -3
- package/dist/tests/allScopesMetadata.test.js +193 -0
- package/dist/tests/stampMetadataContent.test.js +63 -5
- package/dist/tests/syncManifestRefresh.test.js +159 -0
- package/package.json +1 -1
- package/release-manifest.json +231 -222
|
@@ -2,16 +2,23 @@
|
|
|
2
2
|
// Tests for stampMetadataContent — the writer that finalizes each record's
|
|
3
3
|
// metaData.json before it lands on disk.
|
|
4
4
|
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
5
|
+
// The contract is determinism: the same record must produce the same bytes on
|
|
6
|
+
// any instance, from any machine, on every pull. Three on-disk-stability
|
|
7
|
+
// concerns are covered:
|
|
8
|
+
// 1. No generation-time value survives. `_lastUpdatedOn` is deleted outright —
|
|
9
|
+
// it duplicated sys_updated_on.value exactly, and its no-sys_updated_on
|
|
10
|
+
// branch stamped a wall-clock that rewrote the file on every pull.
|
|
8
11
|
// 2. _record_link is rewritten to an instance-relative path. The CADSO metadata
|
|
9
12
|
// endpoint returns an absolute URL (https://<instance>.service-now.com/...);
|
|
10
13
|
// persisting the host makes the artifact instance-bound and dirties every
|
|
11
14
|
// file on a re-pull from a different instance. Stripping any *.service-now.com
|
|
12
15
|
// host keeps the diff to genuine schema changes.
|
|
16
|
+
// 3. display_value is stripped — it is resolved live server-side, so renaming
|
|
17
|
+
// a referenced record re-churns every file pointing at it.
|
|
13
18
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
14
19
|
const appUtils_1 = require("../appUtils");
|
|
20
|
+
// Matches an ISO-8601 wall-clock stamp (what `new Date().toISOString()` emits).
|
|
21
|
+
const ISO_STAMP = /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/;
|
|
15
22
|
const metaFile = (content) => ({
|
|
16
23
|
name: "metaData",
|
|
17
24
|
type: "json",
|
|
@@ -46,13 +53,13 @@ describe("stampMetadataContent — _record_link host stripping", () => {
|
|
|
46
53
|
const out = (0, appUtils_1.stampMetadataContent)(metaFile({ _record_link: null }));
|
|
47
54
|
expect(parse(out)._record_link).toBeNull();
|
|
48
55
|
});
|
|
49
|
-
it("
|
|
56
|
+
it("strips the host while leaving the record's own sys_updated_on intact", () => {
|
|
50
57
|
const out = (0, appUtils_1.stampMetadataContent)(metaFile({
|
|
51
58
|
sys_updated_on: { value: "2025-08-21 17:09:02" },
|
|
52
59
|
_record_link: "https://tenonworkstudio.service-now.com/incident.do?sys_id=1",
|
|
53
60
|
}));
|
|
54
61
|
const parsed = parse(out);
|
|
55
|
-
expect(parsed.
|
|
62
|
+
expect(parsed.sys_updated_on).toEqual({ value: "2025-08-21 17:09:02" });
|
|
56
63
|
expect(parsed._record_link).toBe("/incident.do?sys_id=1");
|
|
57
64
|
});
|
|
58
65
|
it("passes through non-metaData files verbatim", () => {
|
|
@@ -60,6 +67,57 @@ describe("stampMetadataContent — _record_link host stripping", () => {
|
|
|
60
67
|
expect((0, appUtils_1.stampMetadataContent)(script)).toEqual(script);
|
|
61
68
|
});
|
|
62
69
|
});
|
|
70
|
+
describe("stampMetadataContent — no generation-time values", () => {
|
|
71
|
+
it("never writes _lastUpdatedOn, even when sys_updated_on is present", () => {
|
|
72
|
+
const out = (0, appUtils_1.stampMetadataContent)(metaFile({ sys_updated_on: { value: "2025-08-21 17:09:02" } }));
|
|
73
|
+
expect(parse(out)._lastUpdatedOn).toBeUndefined();
|
|
74
|
+
});
|
|
75
|
+
it("deletes a pre-existing _lastUpdatedOn so a re-stamp settles legacy content", () => {
|
|
76
|
+
// A branch pulled before this change carries the key on disk. Re-stamping
|
|
77
|
+
// has to remove it, otherwise the stale value persists forever and the
|
|
78
|
+
// mirror never converges.
|
|
79
|
+
const out = (0, appUtils_1.stampMetadataContent)(metaFile({
|
|
80
|
+
sys_updated_on: { value: "2025-08-21 17:09:02" },
|
|
81
|
+
_lastUpdatedOn: "2025-08-21 17:09:02",
|
|
82
|
+
_record_link: "/incident.do?sys_id=1",
|
|
83
|
+
}));
|
|
84
|
+
const parsed = parse(out);
|
|
85
|
+
expect(parsed._lastUpdatedOn).toBeUndefined();
|
|
86
|
+
expect(parsed._record_link).toBe("/incident.do?sys_id=1");
|
|
87
|
+
});
|
|
88
|
+
it("emits no wall-clock stamp when sys_updated_on is absent", () => {
|
|
89
|
+
// The old fallback stamped new Date().toISOString() here, which is what
|
|
90
|
+
// made every pull dirty the record.
|
|
91
|
+
const out = (0, appUtils_1.stampMetadataContent)(metaFile({ name: { value: "no audit columns" } }));
|
|
92
|
+
expect(out.content).not.toMatch(ISO_STAMP);
|
|
93
|
+
expect(parse(out)._lastUpdatedOn).toBeUndefined();
|
|
94
|
+
});
|
|
95
|
+
it("writes the deterministic placeholder for empty content", () => {
|
|
96
|
+
const out = (0, appUtils_1.stampMetadataContent)({ name: "metaData", type: "json", content: "" });
|
|
97
|
+
expect(out.content).toBe(appUtils_1.EMPTY_METADATA_CONTENT);
|
|
98
|
+
expect(out.content).not.toMatch(ISO_STAMP);
|
|
99
|
+
});
|
|
100
|
+
it("produces identical bytes across repeated runs (the whole point)", () => {
|
|
101
|
+
const server = {
|
|
102
|
+
sys_updated_on: { value: "2025-08-21 17:09:02", display_value: "2025-08-21 10:09:02" },
|
|
103
|
+
action: { value: "abc", display_value: "Some Record Name" },
|
|
104
|
+
_record_link: "https://tenonworkstudio.service-now.com/incident.do?sys_id=1",
|
|
105
|
+
_lastUpdatedOn: "2025-08-21 17:09:02",
|
|
106
|
+
};
|
|
107
|
+
// Two independent pulls of an unchanged record, one re-stamp of the result.
|
|
108
|
+
const first = (0, appUtils_1.stampMetadataContent)(metaFile(server));
|
|
109
|
+
const second = (0, appUtils_1.stampMetadataContent)(metaFile(server));
|
|
110
|
+
const restamped = (0, appUtils_1.stampMetadataContent)(first);
|
|
111
|
+
expect(second.content).toBe(first.content);
|
|
112
|
+
expect(restamped.content).toBe(first.content);
|
|
113
|
+
});
|
|
114
|
+
it("leaves non-object JSON alone rather than throwing", () => {
|
|
115
|
+
const bare = { name: "metaData", type: "json", content: '"just a string"' };
|
|
116
|
+
expect((0, appUtils_1.stampMetadataContent)(bare)).toEqual(bare);
|
|
117
|
+
const arr = { name: "metaData", type: "json", content: "[1,2,3]" };
|
|
118
|
+
expect((0, appUtils_1.stampMetadataContent)(arr)).toEqual(arr);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
63
121
|
describe("stampMetadataContent — display_value stripping", () => {
|
|
64
122
|
it("drops display_value from field pairs, keeping value", () => {
|
|
65
123
|
const out = (0, appUtils_1.stampMetadataContent)(metaFile({
|
|
@@ -285,3 +285,162 @@ describe("syncManifest — refresh pulls instance edits down", function () {
|
|
|
285
285
|
expect(Object.keys(missingArg.sys_script_include).sort()).toEqual(["sysid_RecA", "sysid_RecB"]);
|
|
286
286
|
});
|
|
287
287
|
});
|
|
288
|
+
/**
|
|
289
|
+
* `--metadata-only` exists so a mirror can be settled after a change to the
|
|
290
|
+
* metadata writer without the source churn `--force` produces. The guarantee it
|
|
291
|
+
* has to make is narrow and total: the resulting diff is metaData.json and
|
|
292
|
+
* nothing else — no field file, no manifest, no new record directory.
|
|
293
|
+
*/
|
|
294
|
+
describe("syncManifest — --metadata-only rewrites metadata and nothing else", function () {
|
|
295
|
+
beforeEach(function () {
|
|
296
|
+
jest.clearAllMocks();
|
|
297
|
+
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "sinc-metaonly-test-"));
|
|
298
|
+
mockConfig.getSourcePathForScope.mockReturnValue(tmpRoot);
|
|
299
|
+
mockConfig.getSourcePath.mockReturnValue(tmpRoot);
|
|
300
|
+
});
|
|
301
|
+
afterEach(function () {
|
|
302
|
+
try {
|
|
303
|
+
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
304
|
+
}
|
|
305
|
+
catch (e) { }
|
|
306
|
+
});
|
|
307
|
+
function respondWith(record, files) {
|
|
308
|
+
var records = {};
|
|
309
|
+
records[record] = { name: record, sys_id: "sysid_" + record, files: files };
|
|
310
|
+
mockClient.getMissingFiles.mockResolvedValue({
|
|
311
|
+
sys_script_include: { records: records },
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
test("rewrites metaData.json even though no field file changed", async function () {
|
|
315
|
+
var identical = "var same = true;";
|
|
316
|
+
writeLocal("Rec", "script", "js", identical);
|
|
317
|
+
mockClient.getManifest.mockResolvedValue(setupScope([{ record: "Rec", name: "script", type: "js", content: "" }]));
|
|
318
|
+
respondWith("Rec", [
|
|
319
|
+
{ name: "script", type: "js", content: identical },
|
|
320
|
+
{
|
|
321
|
+
name: "metaData",
|
|
322
|
+
type: "json",
|
|
323
|
+
content: JSON.stringify({
|
|
324
|
+
sys_updated_on: { value: "2026-04-29 14:38:51", display_value: "2026-04-29 07:38:51" },
|
|
325
|
+
_record_link: "https://tenonworkstudio.service-now.com/sys_script_include.do?sys_id=1",
|
|
326
|
+
_lastUpdatedOn: "2026-04-29 14:38:51",
|
|
327
|
+
}),
|
|
328
|
+
},
|
|
329
|
+
]);
|
|
330
|
+
await AppUtils.syncManifest("x_cadso_core", { metadataOnly: true });
|
|
331
|
+
var meta = readLocal("Rec", "metaData", "json");
|
|
332
|
+
expect(meta).not.toBeNull();
|
|
333
|
+
var parsed = JSON.parse(meta);
|
|
334
|
+
// Normalized on the way in — the whole reason to regenerate.
|
|
335
|
+
expect(parsed._record_link).toBe("/sys_script_include.do?sys_id=1");
|
|
336
|
+
expect(parsed._lastUpdatedOn).toBeUndefined();
|
|
337
|
+
expect(meta).not.toContain("display_value");
|
|
338
|
+
});
|
|
339
|
+
test("does not touch field files, even when the instance content differs", async function () {
|
|
340
|
+
writeLocal("Rec", "script", "js", "var local = 1;");
|
|
341
|
+
var beforeMtime = statMtime("Rec", "script", "js");
|
|
342
|
+
mockClient.getManifest.mockResolvedValue(setupScope([{ record: "Rec", name: "script", type: "js", content: "" }]));
|
|
343
|
+
respondWith("Rec", [
|
|
344
|
+
{ name: "script", type: "js", content: "var fromInstance = 2;" },
|
|
345
|
+
{ name: "metaData", type: "json", content: JSON.stringify({ sys_id: { value: "1" } }) },
|
|
346
|
+
]);
|
|
347
|
+
await new Promise(function (r) { setTimeout(r, 10); });
|
|
348
|
+
await AppUtils.syncManifest("x_cadso_core", { metadataOnly: true });
|
|
349
|
+
// Unchanged on disk AND untouched — a rewrite with identical bytes would
|
|
350
|
+
// still be a write, and this mode promises none.
|
|
351
|
+
expect(readLocal("Rec", "script", "js")).toBe("var local = 1;");
|
|
352
|
+
expect(statMtime("Rec", "script", "js")).toBe(beforeMtime);
|
|
353
|
+
});
|
|
354
|
+
test("skips records that are not checked out locally (no orphan directories)", async function () {
|
|
355
|
+
// Writing metaData for a record with no directory would leave a folder
|
|
356
|
+
// holding a lone metaData.json and zero field files.
|
|
357
|
+
//
|
|
358
|
+
// The absent-record check MUST NOT throw. syncManifest swallows errors into
|
|
359
|
+
// logger.error, so an exception here would abort the whole scope while this
|
|
360
|
+
// test's file assertions still passed — "no directory, no metaData" is
|
|
361
|
+
// trivially true when nothing ran. The logger.error assertion is what makes
|
|
362
|
+
// this test real: it caught fUtils.isDirectory rejecting on ENOENT.
|
|
363
|
+
mockClient.getManifest.mockResolvedValue(setupScope([{ record: "NotOnDisk", name: "script", type: "js", content: "" }]));
|
|
364
|
+
respondWith("NotOnDisk", [
|
|
365
|
+
{ name: "script", type: "js", content: "// new on the instance" },
|
|
366
|
+
{ name: "metaData", type: "json", content: JSON.stringify({ sys_id: { value: "1" } }) },
|
|
367
|
+
]);
|
|
368
|
+
await AppUtils.syncManifest("x_cadso_core", { metadataOnly: true });
|
|
369
|
+
expect(mockLogger.error).not.toHaveBeenCalled();
|
|
370
|
+
expect(fs.existsSync(path.join(tmpRoot, "sys_script_include", "NotOnDisk"))).toBe(false);
|
|
371
|
+
expect(readLocal("NotOnDisk", "metaData", "json")).toBeNull();
|
|
372
|
+
});
|
|
373
|
+
test("an absent record does not stop the records that ARE checked out", async function () {
|
|
374
|
+
// The real shape of a metadata-only run: the instance holds records this
|
|
375
|
+
// branch never pulled, interleaved with ones it did. An abort on the first
|
|
376
|
+
// absent record would silently settle only part of the mirror.
|
|
377
|
+
writeLocal("OnDisk", "script", "js", "var x = 1;");
|
|
378
|
+
mockClient.getManifest.mockResolvedValue(setupScope([
|
|
379
|
+
{ record: "Absent", name: "script", type: "js", content: "" },
|
|
380
|
+
{ record: "OnDisk", name: "script", type: "js", content: "" },
|
|
381
|
+
]));
|
|
382
|
+
mockClient.getMissingFiles.mockResolvedValue({
|
|
383
|
+
sys_script_include: {
|
|
384
|
+
records: {
|
|
385
|
+
Absent: {
|
|
386
|
+
name: "Absent",
|
|
387
|
+
sys_id: "sysid_Absent",
|
|
388
|
+
files: [
|
|
389
|
+
{ name: "script", type: "js", content: "// only on the instance" },
|
|
390
|
+
{ name: "metaData", type: "json", content: JSON.stringify({ sys_id: { value: "a" } }) },
|
|
391
|
+
],
|
|
392
|
+
},
|
|
393
|
+
OnDisk: {
|
|
394
|
+
name: "OnDisk",
|
|
395
|
+
sys_id: "sysid_OnDisk",
|
|
396
|
+
files: [
|
|
397
|
+
{ name: "script", type: "js", content: "var x = 1;" },
|
|
398
|
+
{
|
|
399
|
+
name: "metaData",
|
|
400
|
+
type: "json",
|
|
401
|
+
content: JSON.stringify({
|
|
402
|
+
sys_id: { value: "b" },
|
|
403
|
+
_record_link: "https://tenonworkstudio.service-now.com/x.do?sys_id=b",
|
|
404
|
+
}),
|
|
405
|
+
},
|
|
406
|
+
],
|
|
407
|
+
},
|
|
408
|
+
},
|
|
409
|
+
},
|
|
410
|
+
});
|
|
411
|
+
await AppUtils.syncManifest("x_cadso_core", { metadataOnly: true });
|
|
412
|
+
expect(mockLogger.error).not.toHaveBeenCalled();
|
|
413
|
+
// The absent one stayed absent...
|
|
414
|
+
expect(fs.existsSync(path.join(tmpRoot, "sys_script_include", "Absent"))).toBe(false);
|
|
415
|
+
// ...and the present one was still settled.
|
|
416
|
+
var meta = readLocal("OnDisk", "metaData", "json");
|
|
417
|
+
expect(meta).not.toBeNull();
|
|
418
|
+
expect(JSON.parse(meta)._record_link).toBe("/x.do?sys_id=b");
|
|
419
|
+
});
|
|
420
|
+
test("does not persist the manifest", async function () {
|
|
421
|
+
writeLocal("Rec", "script", "js", "var x = 1;");
|
|
422
|
+
mockClient.getManifest.mockResolvedValue(setupScope([{ record: "Rec", name: "script", type: "js", content: "" }]));
|
|
423
|
+
respondWith("Rec", [
|
|
424
|
+
{ name: "script", type: "js", content: "var x = 1;" },
|
|
425
|
+
{ name: "metaData", type: "json", content: JSON.stringify({ sys_id: { value: "1" } }) },
|
|
426
|
+
]);
|
|
427
|
+
await AppUtils.syncManifest("x_cadso_core", { metadataOnly: true });
|
|
428
|
+
// A manifest write would put record adds/drops/renames into a diff that is
|
|
429
|
+
// supposed to be metaData.json only.
|
|
430
|
+
expect(mockConfig.updateManifest).not.toHaveBeenCalled();
|
|
431
|
+
});
|
|
432
|
+
test("a plain refresh still persists the manifest (the gate is scoped)", async function () {
|
|
433
|
+
writeLocal("Rec", "script", "js", "var x = 1;");
|
|
434
|
+
mockClient.getManifest.mockResolvedValue(setupScope([{ record: "Rec", name: "script", type: "js", content: "" }]));
|
|
435
|
+
respondWith("Rec", [{ name: "script", type: "js", content: "var x = 1;" }]);
|
|
436
|
+
await AppUtils.syncManifest("x_cadso_core");
|
|
437
|
+
expect(mockConfig.updateManifest).toHaveBeenCalled();
|
|
438
|
+
});
|
|
439
|
+
test("writes the deterministic placeholder when the server sends no metadata", async function () {
|
|
440
|
+
writeLocal("Rec", "script", "js", "var x = 1;");
|
|
441
|
+
mockClient.getManifest.mockResolvedValue(setupScope([{ record: "Rec", name: "script", type: "js", content: "" }]));
|
|
442
|
+
respondWith("Rec", [{ name: "script", type: "js", content: "var x = 1;" }]);
|
|
443
|
+
await AppUtils.syncManifest("x_cadso_core", { metadataOnly: true });
|
|
444
|
+
expect(readLocal("Rec", "metaData", "json")).toBe(AppUtils.EMPTY_METADATA_CONTENT);
|
|
445
|
+
});
|
|
446
|
+
});
|