@tenonhq/dovetail-core 0.0.113 → 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.
@@ -0,0 +1,193 @@
1
+ "use strict";
2
+ /**
3
+ * Regression: the all-scopes writer must normalize metaData.json, exactly like
4
+ * the `refresh` path does.
5
+ *
6
+ * `processManifestForScope` (allScopesCommands.ts) is what `dove initScopes` and
7
+ * `dove watchAllScopes` write through. It used to `fsp.writeFile` every server
8
+ * file verbatim, so on that path metaData.json kept the server's raw payload:
9
+ *
10
+ * - `_record_link` with the full `https://<instance>.service-now.com` host,
11
+ * making the on-disk artifact instance-bound — re-pull from another instance
12
+ * and every file rewrites;
13
+ * - every field's live `display_value`, which is resolved server-side at pull
14
+ * time, so renaming one referenced record re-churns every file pointing at
15
+ * it (and timestamp display_values render in the *puller's* timezone, so two
16
+ * developers churn each other);
17
+ *
18
+ * and when the server sent no metadata at all it wrote a stub stamped
19
+ * `_generatedAt: new Date().toISOString()` — a value that changed on every run,
20
+ * by construction.
21
+ *
22
+ * The result: records written here diffed against the same records written by
23
+ * `refresh`, and a pull that changed nothing still dirtied the tree. The writer
24
+ * now routes every file through the shared `stampMetadataContent` and writes a
25
+ * deterministic placeholder instead of the timestamp stub.
26
+ */
27
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
28
+ if (k2 === undefined) k2 = k;
29
+ var desc = Object.getOwnPropertyDescriptor(m, k);
30
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
31
+ desc = { enumerable: true, get: function() { return m[k]; } };
32
+ }
33
+ Object.defineProperty(o, k2, desc);
34
+ }) : (function(o, m, k, k2) {
35
+ if (k2 === undefined) k2 = k;
36
+ o[k2] = m[k];
37
+ }));
38
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
39
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
40
+ }) : function(o, v) {
41
+ o["default"] = v;
42
+ });
43
+ var __importStar = (this && this.__importStar) || (function () {
44
+ var ownKeys = function(o) {
45
+ ownKeys = Object.getOwnPropertyNames || function (o) {
46
+ var ar = [];
47
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
48
+ return ar;
49
+ };
50
+ return ownKeys(o);
51
+ };
52
+ return function (mod) {
53
+ if (mod && mod.__esModule) return mod;
54
+ var result = {};
55
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
56
+ __setModuleDefault(result, mod);
57
+ return result;
58
+ };
59
+ })();
60
+ Object.defineProperty(exports, "__esModule", { value: true });
61
+ const fs = __importStar(require("fs"));
62
+ const os = __importStar(require("os"));
63
+ const path = __importStar(require("path"));
64
+ // ---------- mocks (must come before importing the module under test) ----------
65
+ var mockLogger = {
66
+ info: jest.fn(),
67
+ debug: jest.fn(),
68
+ warn: jest.fn(),
69
+ error: jest.fn(),
70
+ success: jest.fn(),
71
+ getLogLevel: function () { return "warn"; },
72
+ };
73
+ var mockFileLogger = {
74
+ debug: jest.fn(),
75
+ info: jest.fn(),
76
+ warn: jest.fn(),
77
+ error: jest.fn(),
78
+ };
79
+ jest.mock("../Logger", function () { return { logger: mockLogger }; });
80
+ jest.mock("../FileLogger", function () { return { fileLogger: mockFileLogger }; });
81
+ jest.mock("../config", function () { return {}; });
82
+ jest.mock("../snClient", function () {
83
+ return {
84
+ defaultClient: function () { return {}; },
85
+ unwrapSNResponse: function (p) { return Promise.resolve(p); },
86
+ };
87
+ });
88
+ jest.mock("../wizard", function () {
89
+ return { setupDotEnv: jest.fn(), getLoginInfo: jest.fn() };
90
+ });
91
+ jest.mock("../commands", function () { return { setLogLevel: jest.fn() }; });
92
+ jest.mock("progress", function () {
93
+ return jest.fn().mockImplementation(function () {
94
+ return { tick: jest.fn() };
95
+ });
96
+ });
97
+ const AllScopes = __importStar(require("../allScopesCommands"));
98
+ const appUtils_1 = require("../appUtils");
99
+ // ---------- helpers ----------
100
+ var tmpRoot;
101
+ var SYS_ID = "24a3a804c3eb7250d4ddf1db05013165";
102
+ var ISO_STAMP = /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/;
103
+ /** The metaData.json payload a real instance returns: absolute link, display_values. */
104
+ function serverMetadata() {
105
+ return JSON.stringify({
106
+ name: { value: "UIFilterApiMS", display_value: "UIFilterApiMS" },
107
+ sys_updated_on: {
108
+ value: "2026-04-29 14:38:51",
109
+ // Rendered in the puller's local timezone — the churn source.
110
+ display_value: "2026-04-29 07:38:51",
111
+ },
112
+ sys_scope: {
113
+ value: "4e4449a5475c255085d19fd8036d43a0",
114
+ display_value: "Tenon Marketing Work Management",
115
+ },
116
+ _table: "sys_script_include",
117
+ _sys_id: SYS_ID,
118
+ _record_link: "https://tenonworkstudio.service-now.com/sys_script_include.do?sys_id=" + SYS_ID,
119
+ _localOnly: true,
120
+ _lastUpdatedOn: "2026-04-29 14:38:51",
121
+ _description: "Complete field metadata for record - DO NOT SYNC TO SERVICENOW",
122
+ });
123
+ }
124
+ function manifestWith(files) {
125
+ return {
126
+ tables: {
127
+ sys_script_include: {
128
+ records: {
129
+ UIFilterApiMS: { name: "UIFilterApiMS", sys_id: SYS_ID, files: files },
130
+ },
131
+ },
132
+ },
133
+ };
134
+ }
135
+ function readRecordFile(name) {
136
+ return fs.readFileSync(path.join(tmpRoot, "sys_script_include", "UIFilterApiMS", name), "utf8");
137
+ }
138
+ describe("all-scopes writer — metaData.json is normalized, not written raw", function () {
139
+ beforeEach(function () {
140
+ jest.clearAllMocks();
141
+ tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "dove-allscopes-meta-"));
142
+ });
143
+ afterEach(function () {
144
+ try {
145
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
146
+ }
147
+ catch (e) { }
148
+ });
149
+ test("strips the instance host from _record_link", async function () {
150
+ await AllScopes.processManifestForScope(manifestWith([{ name: "metaData", type: "json", content: serverMetadata() }]), tmpRoot, true);
151
+ var written = JSON.parse(readRecordFile("metaData.json"));
152
+ expect(written._record_link).toBe("/sys_script_include.do?sys_id=" + SYS_ID);
153
+ expect(readRecordFile("metaData.json")).not.toContain("service-now.com");
154
+ });
155
+ test("strips every display_value, keeping value", async function () {
156
+ 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");
160
+ expect(written.name).toEqual({ value: "UIFilterApiMS" });
161
+ expect(written.sys_updated_on).toEqual({ value: "2026-04-29 14:38:51" });
162
+ expect(written.sys_scope).toEqual({ value: "4e4449a5475c255085d19fd8036d43a0" });
163
+ });
164
+ test("drops _lastUpdatedOn and writes no wall-clock value", async function () {
165
+ await AllScopes.processManifestForScope(manifestWith([{ name: "metaData", type: "json", content: serverMetadata() }]), tmpRoot, true);
166
+ var raw = readRecordFile("metaData.json");
167
+ expect(JSON.parse(raw)._lastUpdatedOn).toBeUndefined();
168
+ expect(raw).not.toMatch(ISO_STAMP);
169
+ });
170
+ test("two runs over the same server payload produce identical bytes", async function () {
171
+ // The contract in one assertion: a pull that changed nothing changes nothing.
172
+ await AllScopes.processManifestForScope(manifestWith([{ name: "metaData", type: "json", content: serverMetadata() }]), tmpRoot, true);
173
+ var first = readRecordFile("metaData.json");
174
+ await AllScopes.processManifestForScope(manifestWith([{ name: "metaData", type: "json", content: serverMetadata() }]), tmpRoot, true);
175
+ expect(readRecordFile("metaData.json")).toBe(first);
176
+ });
177
+ test("writes the deterministic placeholder when the server sends no metadata", async function () {
178
+ await AllScopes.processManifestForScope(manifestWith([{ name: "script", type: "js", content: "// no metadata came back" }]), tmpRoot, true);
179
+ var raw = readRecordFile("metaData.json");
180
+ expect(raw).toBe(appUtils_1.EMPTY_METADATA_CONTENT);
181
+ // The old stub's two churn keys are gone.
182
+ expect(raw).not.toContain("_generatedAt");
183
+ expect(raw).not.toMatch(ISO_STAMP);
184
+ });
185
+ test("leaves regular field files untouched", async function () {
186
+ // stampMetadataContent must be a no-op on anything that isn't metaData.json.
187
+ await AllScopes.processManifestForScope(manifestWith([
188
+ { name: "script", type: "js", content: "var x = 1;\n" },
189
+ { name: "metaData", type: "json", content: serverMetadata() },
190
+ ]), tmpRoot, true);
191
+ expect(readRecordFile("script.js")).toBe("var x = 1;\n");
192
+ });
193
+ });
@@ -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
- // Two on-disk-stability concerns are covered:
6
- // 1. _lastUpdatedOn is sourced from the record's sys_updated_on (stable across
7
- // re-pulls) rather than a fresh wall-clock stamp.
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("still sources _lastUpdatedOn from sys_updated_on while stripping the host", () => {
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._lastUpdatedOn).toBe("2025-08-21 17:09:02");
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({
@@ -0,0 +1,234 @@
1
+ "use strict";
2
+ /**
3
+ * Tests for the single-record narrowing of syncManifest — the `record` option
4
+ * used by `dove create`'s post-create round-trip.
5
+ *
6
+ * Regression target: `dove create <table>` previously called syncManifest(scope)
7
+ * with no narrowing, which re-downloaded and rewrote EVERY record in the scope.
8
+ * Creating one record churned ~1,892 files in a scope like x_cadso_text_spoke.
9
+ * The fix scopes the file refresh to just the created record.
10
+ *
11
+ * These tests assert:
12
+ * 1. narrowManifestToRecord returns only the matching record, never mutating input
13
+ * 2. narrowManifestToRecord returns an empty table map when the record is absent
14
+ * 3. syncManifest({ record }) fetches + writes ONLY the target record's files
15
+ * 4. sibling records already on disk are left untouched (never re-fetched)
16
+ * 5. a record absent from the fetched manifest → warn, no fetch, no throw
17
+ */
18
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
19
+ if (k2 === undefined) k2 = k;
20
+ var desc = Object.getOwnPropertyDescriptor(m, k);
21
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
22
+ desc = { enumerable: true, get: function() { return m[k]; } };
23
+ }
24
+ Object.defineProperty(o, k2, desc);
25
+ }) : (function(o, m, k, k2) {
26
+ if (k2 === undefined) k2 = k;
27
+ o[k2] = m[k];
28
+ }));
29
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
30
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
31
+ }) : function(o, v) {
32
+ o["default"] = v;
33
+ });
34
+ var __importStar = (this && this.__importStar) || (function () {
35
+ var ownKeys = function(o) {
36
+ ownKeys = Object.getOwnPropertyNames || function (o) {
37
+ var ar = [];
38
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
39
+ return ar;
40
+ };
41
+ return ownKeys(o);
42
+ };
43
+ return function (mod) {
44
+ if (mod && mod.__esModule) return mod;
45
+ var result = {};
46
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
47
+ __setModuleDefault(result, mod);
48
+ return result;
49
+ };
50
+ })();
51
+ Object.defineProperty(exports, "__esModule", { value: true });
52
+ const fs = __importStar(require("fs"));
53
+ const os = __importStar(require("os"));
54
+ const path = __importStar(require("path"));
55
+ // ---------- mocks (must come before importing the module under test) ----------
56
+ var mockLogger = {
57
+ info: jest.fn(),
58
+ debug: jest.fn(),
59
+ warn: jest.fn(),
60
+ error: jest.fn(),
61
+ success: jest.fn(),
62
+ getLogLevel: function () { return "warn"; },
63
+ };
64
+ var mockFileLogger = {
65
+ debug: jest.fn(),
66
+ info: jest.fn(),
67
+ warn: jest.fn(),
68
+ error: jest.fn(),
69
+ };
70
+ var mockClient = {
71
+ getManifest: jest.fn(),
72
+ getMissingFiles: jest.fn(),
73
+ };
74
+ jest.mock("../Logger", function () { return { logger: mockLogger }; });
75
+ jest.mock("../FileLogger", function () { return { fileLogger: mockFileLogger }; });
76
+ jest.mock("../snClient", function () {
77
+ return {
78
+ defaultClient: function () { return mockClient; },
79
+ unwrapSNResponse: function (p) { return Promise.resolve(p).then(function (r) { return r; }); },
80
+ processPushResponse: jest.fn(),
81
+ retryOnErr: jest.fn(),
82
+ retryOnHttpErr: jest.fn(),
83
+ unwrapTableAPIFirstItem: jest.fn(),
84
+ };
85
+ });
86
+ var mockConfig = {
87
+ getConfig: jest.fn().mockReturnValue({
88
+ scopes: { x_cadso_core: {} },
89
+ tableOptions: {},
90
+ }),
91
+ getManifest: jest.fn().mockResolvedValue({
92
+ x_cadso_core: { scope: "x_cadso_core", tables: {} },
93
+ }),
94
+ getSourcePathForScope: jest.fn(),
95
+ getSourcePath: jest.fn(),
96
+ getManifestPath: jest.fn().mockReturnValue("/tmp/dove.manifest.json"),
97
+ resolveConfigForScope: jest.fn().mockImplementation(function () {
98
+ return {
99
+ tables: ["sys_script_include"],
100
+ fieldOverrides: {},
101
+ apiIncludes: {},
102
+ apiExcludes: {},
103
+ };
104
+ }),
105
+ isMultiScopeManifest: jest.fn().mockReturnValue(true),
106
+ updateManifest: jest.fn(),
107
+ };
108
+ jest.mock("../config", function () { return mockConfig; });
109
+ jest.mock("progress", function () {
110
+ return jest.fn().mockImplementation(function () {
111
+ return { tick: jest.fn() };
112
+ });
113
+ });
114
+ // Stub writeScopeManifest so the test never writes a manifest file. Leave the
115
+ // other FileUtils exports real so we can assert against the file system.
116
+ jest.mock("../FileUtils", function () {
117
+ var actual = jest.requireActual("../FileUtils");
118
+ return Object.assign({}, actual, {
119
+ writeScopeManifest: jest.fn().mockResolvedValue(undefined),
120
+ });
121
+ });
122
+ const AppUtils = __importStar(require("../appUtils"));
123
+ // ---------- fixtures ----------
124
+ // A three-record scope manifest, all in sys_script_include, matching the shape
125
+ // client.getManifest returns (files carry name+type only; no content).
126
+ function threeRecordManifest() {
127
+ return {
128
+ scope: "x_cadso_core",
129
+ tables: {
130
+ sys_script_include: {
131
+ records: {
132
+ New: { name: "New", sys_id: "sysid_New", files: [{ name: "script", type: "js" }] },
133
+ Sib1: { name: "Sib1", sys_id: "sysid_Sib1", files: [{ name: "script", type: "js" }] },
134
+ Sib2: { name: "Sib2", sys_id: "sysid_Sib2", files: [{ name: "script", type: "js" }] },
135
+ },
136
+ },
137
+ },
138
+ };
139
+ }
140
+ var tmpRoot;
141
+ function writeLocal(record, name, type, content) {
142
+ var recDir = path.join(tmpRoot, "sys_script_include", record);
143
+ fs.mkdirSync(recDir, { recursive: true });
144
+ fs.writeFileSync(path.join(recDir, name + "." + type), content);
145
+ }
146
+ function readLocal(record, name, type) {
147
+ var p = path.join(tmpRoot, "sys_script_include", record, name + "." + type);
148
+ try {
149
+ return fs.readFileSync(p, "utf8");
150
+ }
151
+ catch (e) {
152
+ return null;
153
+ }
154
+ }
155
+ // ---------- pure helper ----------
156
+ describe("narrowManifestToRecord", function () {
157
+ test("returns only the matching record and does NOT mutate the input", function () {
158
+ var manifest = threeRecordManifest();
159
+ var out = AppUtils.narrowManifestToRecord(manifest, "sys_script_include", "sysid_New");
160
+ // input untouched — all three records still present
161
+ expect(Object.keys(manifest.tables.sys_script_include.records).sort())
162
+ .toEqual(["New", "Sib1", "Sib2"]);
163
+ // output holds exactly the one record
164
+ expect(Object.keys(out.tables)).toEqual(["sys_script_include"]);
165
+ expect(Object.keys(out.tables.sys_script_include.records)).toEqual(["New"]);
166
+ expect(out.tables.sys_script_include.records.New.sys_id).toBe("sysid_New");
167
+ });
168
+ test("returns an empty table map when the sys_id is absent", function () {
169
+ var manifest = threeRecordManifest();
170
+ var out = AppUtils.narrowManifestToRecord(manifest, "sys_script_include", "sysid_Missing");
171
+ expect(Object.keys(out.tables)).toEqual([]);
172
+ });
173
+ test("returns an empty table map when the table is absent", function () {
174
+ var manifest = threeRecordManifest();
175
+ var out = AppUtils.narrowManifestToRecord(manifest, "sys_ui_action", "sysid_New");
176
+ expect(Object.keys(out.tables)).toEqual([]);
177
+ });
178
+ });
179
+ // ---------- syncManifest({ record }) ----------
180
+ describe("syncManifest — single-record round-trip (dove create)", function () {
181
+ beforeEach(function () {
182
+ jest.clearAllMocks();
183
+ tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "sinc-record-test-"));
184
+ mockConfig.getSourcePathForScope.mockReturnValue(tmpRoot);
185
+ mockConfig.getSourcePath.mockReturnValue(tmpRoot);
186
+ });
187
+ afterEach(function () {
188
+ try {
189
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
190
+ }
191
+ catch (e) { }
192
+ });
193
+ test("fetches + writes ONLY the target record; siblings are never re-fetched", async function () {
194
+ // A stale sibling file already on disk — it must be left exactly as-is.
195
+ writeLocal("Sib1", "script", "js", "STALE SIBLING — must not change");
196
+ mockClient.getManifest.mockResolvedValue(threeRecordManifest());
197
+ // getMissingFiles should be asked for the new record ONLY; return its content.
198
+ mockClient.getMissingFiles.mockResolvedValue({
199
+ sys_script_include: {
200
+ records: {
201
+ New: {
202
+ name: "New", sys_id: "sysid_New",
203
+ files: [{ name: "script", type: "js", content: "// fresh created record" }],
204
+ },
205
+ },
206
+ },
207
+ });
208
+ await AppUtils.syncManifest("x_cadso_core", {
209
+ record: { table: "sys_script_include", sysId: "sysid_New" },
210
+ });
211
+ // exactly one content fetch, scoped to the one record
212
+ expect(mockClient.getMissingFiles).toHaveBeenCalledTimes(1);
213
+ var missingArg = mockClient.getMissingFiles.mock.calls[0][0];
214
+ expect(Object.keys(missingArg.sys_script_include)).toEqual(["sysid_New"]);
215
+ // the new record landed
216
+ expect(readLocal("New", "script", "js")).toBe("// fresh created record");
217
+ // siblings untouched
218
+ expect(readLocal("Sib1", "script", "js")).toBe("STALE SIBLING — must not change");
219
+ expect(readLocal("Sib2", "script", "js")).toBeNull();
220
+ });
221
+ test("record absent from the fetched manifest → warn, no fetch, no throw", async function () {
222
+ mockClient.getManifest.mockResolvedValue(threeRecordManifest());
223
+ await AppUtils.syncManifest("x_cadso_core", {
224
+ record: { table: "sys_script_include", sysId: "sysid_DoesNotExist" },
225
+ });
226
+ // empty narrowed manifest → refreshAllFiles no-ops, so no content fetch
227
+ expect(mockClient.getMissingFiles).not.toHaveBeenCalled();
228
+ // and the user is told to run a full refresh
229
+ var warned = mockLogger.warn.mock.calls.some(function (c) {
230
+ return typeof c[0] === "string" && c[0].indexOf("sysid_DoesNotExist") !== -1;
231
+ });
232
+ expect(warned).toBe(true);
233
+ });
234
+ });