@tenonhq/dovetail-core 0.0.124 → 0.0.125

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,609 @@
1
+ "use strict";
2
+ /**
3
+ * Tests for the per-record pull (`dove pull <table> <sysId>`, TenonHQ/Dovetail#319).
4
+ *
5
+ * The contract under test, straight from the issue's acceptance:
6
+ * 1. a pull on a clean tree produces exactly ONE new record folder and ONE new
7
+ * manifest key — every other manifest entry stays byte-identical;
8
+ * 2. a record in another scope than --scope fails with a clear message and
9
+ * writes NOTHING (no content fetch, no file, no manifest);
10
+ * 3. a table the server-side manifest skips (no script/html column, no field
11
+ * override) still mirrors — metaData.json only — instead of being skipped;
12
+ * 4. re-running is a no-op (idempotent), and planPull alone never writes;
13
+ * 5. --from-update-set expands to the set's per-record captures and skips the
14
+ * schema / choice-set / DELETE captures with a reason;
15
+ * 6. the CLI never silently no-ops: a stray `-t` and a missing sys_id are errors.
16
+ */
17
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
18
+ if (k2 === undefined) k2 = k;
19
+ var desc = Object.getOwnPropertyDescriptor(m, k);
20
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
21
+ desc = { enumerable: true, get: function() { return m[k]; } };
22
+ }
23
+ Object.defineProperty(o, k2, desc);
24
+ }) : (function(o, m, k, k2) {
25
+ if (k2 === undefined) k2 = k;
26
+ o[k2] = m[k];
27
+ }));
28
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
29
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
30
+ }) : function(o, v) {
31
+ o["default"] = v;
32
+ });
33
+ var __importStar = (this && this.__importStar) || (function () {
34
+ var ownKeys = function(o) {
35
+ ownKeys = Object.getOwnPropertyNames || function (o) {
36
+ var ar = [];
37
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
38
+ return ar;
39
+ };
40
+ return ownKeys(o);
41
+ };
42
+ return function (mod) {
43
+ if (mod && mod.__esModule) return mod;
44
+ var result = {};
45
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
46
+ __setModuleDefault(result, mod);
47
+ return result;
48
+ };
49
+ })();
50
+ Object.defineProperty(exports, "__esModule", { value: true });
51
+ const fs = __importStar(require("fs"));
52
+ const os = __importStar(require("os"));
53
+ const path = __importStar(require("path"));
54
+ // ---------- mocks (must come before importing the module under test) ----------
55
+ var mockLogger = {
56
+ info: jest.fn(),
57
+ debug: jest.fn(),
58
+ warn: jest.fn(),
59
+ error: jest.fn(),
60
+ success: jest.fn(),
61
+ setLogLevel: jest.fn(),
62
+ getLogLevel: function () { return "warn"; },
63
+ };
64
+ var mockFileLogger = { debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn() };
65
+ var mockClient = {
66
+ getRecordScope: jest.fn(),
67
+ getManifest: jest.fn(),
68
+ getMissingFiles: jest.fn(),
69
+ getUpdateSetMembers: jest.fn(),
70
+ };
71
+ jest.mock("../Logger", function () { return { logger: mockLogger }; });
72
+ jest.mock("../FileLogger", function () { return { fileLogger: mockFileLogger }; });
73
+ jest.mock("../snClient", function () {
74
+ return {
75
+ defaultClient: function () { return mockClient; },
76
+ unwrapSNResponse: function (p) {
77
+ return Promise.resolve(p).then(function (r) {
78
+ // Real unwrapSNResponse returns resp.data.result; the mocks resolve the
79
+ // bare result, so pass through.
80
+ return r;
81
+ });
82
+ },
83
+ processPushResponse: jest.fn(),
84
+ retryOnErr: jest.fn(),
85
+ retryOnHttpErr: jest.fn(),
86
+ unwrapTableAPIFirstItem: jest.fn(),
87
+ setBenchmarkSink: jest.fn(),
88
+ };
89
+ });
90
+ var tmpRoot = "";
91
+ var mockConfig = {
92
+ getConfig: jest.fn(),
93
+ getRootDir: jest.fn(function () { return tmpRoot; }),
94
+ getScopeManifestPath: jest.fn(function (scope) {
95
+ return path.join(tmpRoot, "dove.manifest." + scope + ".json");
96
+ }),
97
+ getSourcePathForScope: jest.fn(function (scope) {
98
+ return path.join(tmpRoot, "src", scope);
99
+ }),
100
+ getSourcePath: jest.fn(function () { return path.join(tmpRoot, "src"); }),
101
+ getManifestPath: jest.fn(function () { return path.join(tmpRoot, "dove.manifest.json"); }),
102
+ resolveConfigForScope: jest.fn(),
103
+ isMultiScopeManifest: jest.fn().mockReturnValue(true),
104
+ updateManifest: jest.fn(),
105
+ getManifest: jest.fn(),
106
+ };
107
+ jest.mock("../config", function () { return mockConfig; });
108
+ const pullRecord_1 = require("../pullRecord");
109
+ const pullRecordCommand_1 = require("../pullRecordCommand");
110
+ // ---------- fixtures ----------
111
+ const SCOPE = "x_cadso_journey";
112
+ const ID_NEW = "94243ee5c3f78f10d4ddf1db050131a6";
113
+ const ID_SIB = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1";
114
+ const ID_OTHER_SCOPE = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb2";
115
+ const ID_META_ONLY = "ccccccccccccccccccccccccccccccc3";
116
+ function config() {
117
+ return {
118
+ scopes: { [SCOPE]: { sourceDirectory: "src/" + SCOPE }, x_cadso_core: {} },
119
+ tableOptions: {},
120
+ includes: { _tables: ["sys_script_include", "sys_security_acl"] },
121
+ };
122
+ }
123
+ function resolvedScope() {
124
+ return {
125
+ tables: ["sys_script_include", "sys_security_acl"],
126
+ fieldOverrides: { sys_script_include: { script: { type: "js" } } },
127
+ apiIncludes: {},
128
+ apiExcludes: {},
129
+ readOnlyTables: [],
130
+ };
131
+ }
132
+ // The structure-only manifest getManifest returns for the scope: the sibling is
133
+ // already mirrored locally; the new ACL is on the instance but not in the repo.
134
+ function serverManifest() {
135
+ return {
136
+ scope: SCOPE,
137
+ tables: {
138
+ sys_script_include: {
139
+ records: {
140
+ Sibling: { name: "Sibling", sys_id: ID_SIB, files: [{ name: "script", type: "js" }] },
141
+ },
142
+ },
143
+ sys_security_acl: {
144
+ records: {
145
+ "x_cadso_journey_skill.*": {
146
+ name: "x_cadso_journey_skill.*",
147
+ sys_id: ID_NEW,
148
+ files: [{ name: "script", type: "js" }],
149
+ },
150
+ },
151
+ },
152
+ },
153
+ };
154
+ }
155
+ function scopeRead(scope, cls) {
156
+ return function (table, sysId) {
157
+ return Promise.resolve({
158
+ sys_id: sysId,
159
+ sys_class_name: cls === undefined ? table : cls,
160
+ sys_scope: { value: "scope-sys-id", link: "x" },
161
+ "sys_scope.scope": scope,
162
+ });
163
+ };
164
+ }
165
+ function metaContent(table, sysId, name) {
166
+ return JSON.stringify({
167
+ sys_id: { value: sysId, display_value: sysId },
168
+ name: { value: name, display_value: name },
169
+ sys_updated_on: { value: "2026-10-07 10:00:00", display_value: "2026-10-07 03:00:00" },
170
+ _table: table,
171
+ _sys_id: sysId,
172
+ _name: name,
173
+ _record_link: "https://tenonworkstudio.service-now.com/" + table + ".do?sys_id=" + sysId,
174
+ _lastUpdatedOn: "2026-10-07 10:00:00",
175
+ }, null, 2);
176
+ }
177
+ function bulkDownload(table, sysId, name, files) {
178
+ const out = {};
179
+ out[table] = {
180
+ records: {
181
+ [name]: {
182
+ name,
183
+ sys_id: sysId,
184
+ files: files.concat([{ name: "metaData", type: "json", content: metaContent(table, sysId, name) }]),
185
+ },
186
+ },
187
+ };
188
+ return out;
189
+ }
190
+ function writeLocalManifest(man, trailingNewline) {
191
+ fs.writeFileSync(path.join(tmpRoot, "dove.manifest." + SCOPE + ".json"), JSON.stringify(man, null, 2) + (trailingNewline ? "\n" : ""));
192
+ }
193
+ function readLocalManifest() {
194
+ return fs.readFileSync(path.join(tmpRoot, "dove.manifest." + SCOPE + ".json"), "utf8");
195
+ }
196
+ function localManifestWithSibling() {
197
+ return {
198
+ tables: {
199
+ sys_script_include: {
200
+ records: {
201
+ Sibling: { files: [{ name: "script", type: "js" }], name: "Sibling", sys_id: ID_SIB },
202
+ },
203
+ },
204
+ },
205
+ scope: SCOPE,
206
+ };
207
+ }
208
+ /** Recursive listing of every file under root, relative, with content. */
209
+ function snapshot(root) {
210
+ const out = {};
211
+ function walk(dir) {
212
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
213
+ const p = path.join(dir, entry.name);
214
+ if (entry.isDirectory())
215
+ walk(p);
216
+ else
217
+ out[path.relative(root, p)] = fs.readFileSync(p, "utf8");
218
+ }
219
+ }
220
+ walk(root);
221
+ return out;
222
+ }
223
+ function seedSibling() {
224
+ const dir = path.join(tmpRoot, "src", SCOPE, "sys_script_include", "Sibling");
225
+ fs.mkdirSync(dir, { recursive: true });
226
+ fs.writeFileSync(path.join(dir, "script.js"), "// sibling — must not change");
227
+ fs.writeFileSync(path.join(dir, "metaData.json"), "{}");
228
+ }
229
+ beforeEach(function () {
230
+ jest.clearAllMocks();
231
+ tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "dove-pull-test-"));
232
+ mockConfig.getConfig.mockReturnValue(config());
233
+ mockConfig.resolveConfigForScope.mockReturnValue(resolvedScope());
234
+ mockClient.getRecordScope.mockImplementation(scopeRead(SCOPE));
235
+ mockClient.getManifest.mockResolvedValue(serverManifest());
236
+ mockClient.getMissingFiles.mockResolvedValue(bulkDownload("sys_security_acl", ID_NEW, "x_cadso_journey_skill.*", [
237
+ { name: "script", type: "js", content: "answer = true;" },
238
+ ]));
239
+ process.exitCode = undefined;
240
+ });
241
+ afterEach(function () {
242
+ try {
243
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
244
+ }
245
+ catch (e) { /* ignore */ }
246
+ process.exitCode = undefined;
247
+ });
248
+ // ---------- pure helpers ----------
249
+ describe("normalizeSysIds", function () {
250
+ test("accepts positional, comma-separated and mixed-case; de-dupes", function () {
251
+ expect((0, pullRecord_1.normalizeSysIds)([ID_NEW, ID_SIB + "," + ID_NEW.toUpperCase()])).toEqual([ID_NEW, ID_SIB]);
252
+ });
253
+ test("rejects a malformed sys_id before any network call", function () {
254
+ expect(function () { (0, pullRecord_1.normalizeSysIds)(["not-a-sys-id"]); }).toThrow(/not a sys_id/);
255
+ });
256
+ test("empty input → empty list", function () {
257
+ expect((0, pullRecord_1.normalizeSysIds)(undefined)).toEqual([]);
258
+ expect((0, pullRecord_1.normalizeSysIds)([undefined, ""])).toEqual([]);
259
+ });
260
+ });
261
+ describe("parseUpdateXmlName", function () {
262
+ test("parses a per-record capture", function () {
263
+ expect((0, pullRecord_1.parseUpdateXmlName)("sys_one_extend_capability_" + ID_NEW)).toEqual({
264
+ table: "sys_one_extend_capability",
265
+ sysId: ID_NEW,
266
+ });
267
+ });
268
+ test("ignores schema / choice-set captures", function () {
269
+ expect((0, pullRecord_1.parseUpdateXmlName)("sys_dictionary_x_cadso_journey_run_status")).toBeUndefined();
270
+ expect((0, pullRecord_1.parseUpdateXmlName)("sys_db_object_x_cadso_journey_run")).toBeUndefined();
271
+ expect((0, pullRecord_1.parseUpdateXmlName)("")).toBeUndefined();
272
+ });
273
+ });
274
+ describe("spliceManifestRecord", function () {
275
+ const entry = { files: [{ name: "script", type: "js" }], name: "New", sys_id: ID_NEW };
276
+ test("adds one key and leaves every other entry byte-identical", function () {
277
+ const before = localManifestWithSibling();
278
+ const beforeText = JSON.stringify(before, null, 2);
279
+ const out = (0, pullRecord_1.spliceManifestRecord)(before, SCOPE, "sys_security_acl", entry);
280
+ // input not mutated
281
+ expect(JSON.stringify(before, null, 2)).toBe(beforeText);
282
+ // other table untouched, new table appended, scope kept last
283
+ expect(Object.keys(out)).toEqual(["tables", "scope"]);
284
+ expect(Object.keys(out.tables)).toEqual(["sys_script_include", "sys_security_acl"]);
285
+ expect(JSON.stringify(out.tables.sys_script_include)).toBe(JSON.stringify(before.tables.sys_script_include));
286
+ expect(out.tables.sys_security_acl.records).toEqual({ New: entry });
287
+ });
288
+ test("replaces an entry under the same key in place (order preserved)", function () {
289
+ const man = {
290
+ tables: { sys_script_include: { records: {
291
+ A: { files: [], name: "A", sys_id: "a".repeat(32) },
292
+ Sibling: { files: [], name: "Sibling", sys_id: ID_SIB },
293
+ Z: { files: [], name: "Z", sys_id: "f".repeat(32) },
294
+ } } },
295
+ scope: SCOPE,
296
+ };
297
+ const out = (0, pullRecord_1.spliceManifestRecord)(man, SCOPE, "sys_script_include", {
298
+ files: [{ name: "script", type: "js" }], name: "Sibling", sys_id: ID_SIB,
299
+ });
300
+ expect(Object.keys(out.tables.sys_script_include.records)).toEqual(["A", "Sibling", "Z"]);
301
+ expect(out.tables.sys_script_include.records.Sibling.files).toEqual([{ name: "script", type: "js" }]);
302
+ });
303
+ test("drops the stale key when the record was renamed on the instance", function () {
304
+ const man = localManifestWithSibling();
305
+ const out = (0, pullRecord_1.spliceManifestRecord)(man, SCOPE, "sys_script_include", { files: [{ name: "script", type: "js" }], name: "Renamed", sys_id: ID_SIB }, "Sibling");
306
+ expect(Object.keys(out.tables.sys_script_include.records)).toEqual(["Renamed"]);
307
+ });
308
+ test("creates a manifest when none exists", function () {
309
+ const out = (0, pullRecord_1.spliceManifestRecord)(undefined, SCOPE, "sys_security_acl", entry);
310
+ expect(out).toEqual({ tables: { sys_security_acl: { records: { New: entry } } }, scope: SCOPE });
311
+ });
312
+ });
313
+ describe("parseManifestText / serializeManifest", function () {
314
+ test("detects Dovetail's own formatting and preserves the trailing newline", function () {
315
+ const man = localManifestWithSibling();
316
+ const withNl = (0, pullRecord_1.parseManifestText)(JSON.stringify(man, null, 2) + "\n");
317
+ expect(withNl.formatPreserved).toBe(true);
318
+ expect(withNl.trailingNewline).toBe(true);
319
+ expect((0, pullRecord_1.serializeManifest)(withNl.manifest, withNl.trailingNewline)).toBe(JSON.stringify(man, null, 2) + "\n");
320
+ const noNl = (0, pullRecord_1.parseManifestText)(JSON.stringify(man, null, 2));
321
+ expect(noNl.trailingNewline).toBe(false);
322
+ });
323
+ test("flags a manifest that would not round-trip (e.g. tab-indented)", function () {
324
+ const parsed = (0, pullRecord_1.parseManifestText)(JSON.stringify(localManifestWithSibling(), null, "\t"));
325
+ expect(parsed.formatPreserved).toBe(false);
326
+ });
327
+ test("missing file → no manifest, format trivially preserved", function () {
328
+ expect((0, pullRecord_1.parseManifestText)(undefined)).toEqual({
329
+ manifest: undefined, formatPreserved: true, trailingNewline: false, existed: false,
330
+ });
331
+ });
332
+ test("rejects non-JSON and non-object manifests", function () {
333
+ expect(function () { (0, pullRecord_1.parseManifestText)("{nope"); }).toThrow(/not valid JSON/);
334
+ expect(function () { (0, pullRecord_1.parseManifestText)("[]"); }).toThrow(/not a JSON object/);
335
+ });
336
+ });
337
+ // ---------- plan + apply ----------
338
+ describe("planPull + applyPull — acceptance", function () {
339
+ test("one new folder, one new manifest key, nothing else touched", async function () {
340
+ seedSibling();
341
+ writeLocalManifest(localManifestWithSibling(), true);
342
+ const before = snapshot(tmpRoot);
343
+ const plan = await (0, pullRecord_1.planPull)([{ table: "sys_security_acl", sysId: ID_NEW }], { scope: SCOPE });
344
+ // planPull is read-only
345
+ expect(snapshot(tmpRoot)).toEqual(before);
346
+ expect(plan.records).toHaveLength(1);
347
+ const rec = plan.records[0];
348
+ expect(rec.key).toBe(ID_NEW); // `<table>.*` is unsafe on Windows → sys_id folder, as refresh does
349
+ expect(rec.manifestAction).toBe("add");
350
+ expect(rec.inServerManifest).toBe(true);
351
+ expect(rec.inWhitelist).toBe(true);
352
+ expect(rec.files.map(function (f) { return path.basename(f.path) + ":" + f.action; }))
353
+ .toEqual(["script.js:write", "metaData.json:write"]);
354
+ // content was fetched for exactly this one record, with its server-side file list
355
+ expect(mockClient.getMissingFiles).toHaveBeenCalledTimes(1);
356
+ expect(mockClient.getMissingFiles.mock.calls[0][0]).toEqual({
357
+ sys_security_acl: { [ID_NEW]: [{ name: "script", type: "js" }] },
358
+ });
359
+ const result = await (0, pullRecord_1.applyPull)(plan);
360
+ expect(result.filesWritten).toBe(2);
361
+ expect(result.manifestsWritten).toHaveLength(1);
362
+ const after = snapshot(tmpRoot);
363
+ const changed = Object.keys(after).filter(function (k) { return after[k] !== before[k]; }).sort();
364
+ expect(changed).toEqual([
365
+ "dove.manifest." + SCOPE + ".json",
366
+ path.join("src", SCOPE, "sys_security_acl", ID_NEW, "metaData.json"),
367
+ path.join("src", SCOPE, "sys_security_acl", ID_NEW, "script.js"),
368
+ ]);
369
+ expect(after[path.join("src", SCOPE, "sys_security_acl", ID_NEW, "script.js")]).toBe("answer = true;");
370
+ // metaData is stamped like refresh: host stripped, _lastUpdatedOn gone, datetime display dropped
371
+ const meta = JSON.parse(after[path.join("src", SCOPE, "sys_security_acl", ID_NEW, "metaData.json")]);
372
+ expect(meta._record_link).toBe("/sys_security_acl.do?sys_id=" + ID_NEW);
373
+ expect(meta._lastUpdatedOn).toBeUndefined();
374
+ expect(meta.sys_updated_on).toEqual({ value: "2026-10-07 10:00:00" });
375
+ // the manifest gained exactly one key; the sibling entry is byte-identical; newline kept
376
+ const text = readLocalManifest();
377
+ expect(text.endsWith("\n")).toBe(true);
378
+ const man = JSON.parse(text);
379
+ expect(Object.keys(man.tables)).toEqual(["sys_script_include", "sys_security_acl"]);
380
+ expect(man.tables.sys_security_acl.records).toEqual({
381
+ [ID_NEW]: { files: [{ name: "script", type: "js" }], name: ID_NEW, sys_id: ID_NEW },
382
+ });
383
+ const expected = JSON.parse(before["dove.manifest." + SCOPE + ".json"]);
384
+ expected.tables.sys_security_acl = man.tables.sys_security_acl;
385
+ expect(text).toBe(JSON.stringify(expected, null, 2) + "\n");
386
+ });
387
+ test("re-running is a no-op: every file unchanged, manifest not rewritten", async function () {
388
+ writeLocalManifest(localManifestWithSibling(), false);
389
+ await (0, pullRecord_1.applyPull)(await (0, pullRecord_1.planPull)([{ table: "sys_security_acl", sysId: ID_NEW }]));
390
+ const once = snapshot(tmpRoot);
391
+ const plan = await (0, pullRecord_1.planPull)([{ table: "sys_security_acl", sysId: ID_NEW }]);
392
+ expect(plan.records[0].manifestAction).toBe("unchanged");
393
+ expect(plan.records[0].files.every(function (f) { return f.action === "unchanged"; })).toBe(true);
394
+ const result = await (0, pullRecord_1.applyPull)(plan);
395
+ expect(result.filesWritten).toBe(0);
396
+ expect(result.manifestsWritten).toEqual([]);
397
+ expect(snapshot(tmpRoot)).toEqual(once);
398
+ });
399
+ test("--force rewrites files that already match", async function () {
400
+ await (0, pullRecord_1.applyPull)(await (0, pullRecord_1.planPull)([{ table: "sys_security_acl", sysId: ID_NEW }]));
401
+ const plan = await (0, pullRecord_1.planPull)([{ table: "sys_security_acl", sysId: ID_NEW }], { force: true });
402
+ expect(plan.records[0].files.every(function (f) { return f.action === "write"; })).toBe(true);
403
+ });
404
+ test("creates the scope manifest when the branch has none", async function () {
405
+ const plan = await (0, pullRecord_1.planPull)([{ table: "sys_security_acl", sysId: ID_NEW }]);
406
+ expect(plan.manifests[0].existed).toBe(false);
407
+ await (0, pullRecord_1.applyPull)(plan);
408
+ expect(JSON.parse(readLocalManifest())).toEqual({
409
+ tables: { sys_security_acl: { records: {
410
+ [ID_NEW]: { files: [{ name: "script", type: "js" }], name: ID_NEW, sys_id: ID_NEW },
411
+ } } },
412
+ scope: SCOPE,
413
+ });
414
+ });
415
+ });
416
+ describe("planPull — scope refusal (writes nothing)", function () {
417
+ test("record in another scope than --scope → clear error, no fetch, no write", async function () {
418
+ writeLocalManifest(localManifestWithSibling(), true);
419
+ const before = snapshot(tmpRoot);
420
+ mockClient.getRecordScope.mockImplementation(scopeRead("x_cadso_core"));
421
+ await expect((0, pullRecord_1.planPull)([{ table: "sys_security_acl", sysId: ID_OTHER_SCOPE }], { scope: SCOPE })).rejects.toThrow(/belongs to scope 'x_cadso_core', not the requested 'x_cadso_journey'/);
422
+ expect(mockClient.getManifest).not.toHaveBeenCalled();
423
+ expect(mockClient.getMissingFiles).not.toHaveBeenCalled();
424
+ expect(snapshot(tmpRoot)).toEqual(before);
425
+ });
426
+ test("one bad record in a batch refuses the WHOLE batch", async function () {
427
+ mockClient.getRecordScope.mockImplementation(function (table, sysId) {
428
+ return scopeRead(sysId === ID_OTHER_SCOPE ? "x_cadso_core" : SCOPE)(table, sysId);
429
+ });
430
+ await expect((0, pullRecord_1.planPull)([{ table: "sys_security_acl", sysId: ID_NEW }, { table: "sys_security_acl", sysId: ID_OTHER_SCOPE }], { scope: SCOPE })).rejects.toThrow(/Refusing to pull/);
431
+ expect(mockClient.getMissingFiles).not.toHaveBeenCalled();
432
+ });
433
+ test("record in a scope not declared in dove.config.js → refused", async function () {
434
+ mockClient.getRecordScope.mockImplementation(scopeRead("x_cadso_undeclared"));
435
+ await expect((0, pullRecord_1.planPull)([{ table: "sys_security_acl", sysId: ID_NEW }]))
436
+ .rejects.toThrow(/not declared in dove.config.js/);
437
+ expect(mockClient.getMissingFiles).not.toHaveBeenCalled();
438
+ });
439
+ test("--scope that is not declared → refused before any read", async function () {
440
+ await expect((0, pullRecord_1.planPull)([{ table: "sys_security_acl", sysId: ID_NEW }], { scope: "x_nope" }))
441
+ .rejects.toThrow(/--scope 'x_nope' is not declared/);
442
+ expect(mockClient.getRecordScope).not.toHaveBeenCalled();
443
+ });
444
+ test("record not found (404) → named clearly", async function () {
445
+ mockClient.getRecordScope.mockRejectedValue({ response: { status: 404 } });
446
+ await expect((0, pullRecord_1.planPull)([{ table: "sys_security_acl", sysId: ID_NEW }]))
447
+ .rejects.toThrow(/not found on the instance/);
448
+ });
449
+ test("record read through a parent class → refused, names the real class", async function () {
450
+ mockClient.getRecordScope.mockImplementation(scopeRead(SCOPE, "sys_script_include"));
451
+ await expect((0, pullRecord_1.planPull)([{ table: "sys_metadata", sysId: ID_NEW }]))
452
+ .rejects.toThrow(/class is 'sys_script_include', not 'sys_metadata'/);
453
+ });
454
+ test("record with no sys_scope needs --scope", async function () {
455
+ mockClient.getRecordScope.mockResolvedValue({ sys_id: ID_NEW, sys_scope: "", "sys_scope.scope": "" });
456
+ await expect((0, pullRecord_1.planPull)([{ table: "sys_choice", sysId: ID_NEW }]))
457
+ .rejects.toThrow(/carries no sys_scope/);
458
+ // with --scope it is placed there
459
+ mockClient.getMissingFiles.mockResolvedValue(bulkDownload("sys_choice", ID_NEW, "x_cadso_journey_run.status.active", []));
460
+ const plan = await (0, pullRecord_1.planPull)([{ table: "sys_choice", sysId: ID_NEW }], { scope: SCOPE });
461
+ expect(plan.records[0].scope).toBe(SCOPE);
462
+ });
463
+ });
464
+ describe("planPull — tables the server-side manifest skips", function () {
465
+ test("no script/html column, no override → metaData.json only, with a warning", async function () {
466
+ mockClient.getMissingFiles.mockResolvedValue(bulkDownload("sys_one_extend_capability", ID_META_ONLY, "Skill Capability", []));
467
+ const plan = await (0, pullRecord_1.planPull)([{ table: "sys_one_extend_capability", sysId: ID_META_ONLY }]);
468
+ const rec = plan.records[0];
469
+ expect(rec.inServerManifest).toBe(false);
470
+ expect(rec.inWhitelist).toBe(false);
471
+ expect(rec.key).toBe("Skill Capability");
472
+ // asked the server for the record with an EMPTY field list → it still answers with metaData
473
+ expect(mockClient.getMissingFiles.mock.calls[0][0]).toEqual({
474
+ sys_one_extend_capability: { [ID_META_ONLY]: [] },
475
+ });
476
+ expect(rec.files.map(function (f) { return path.basename(f.path); })).toEqual(["metaData.json"]);
477
+ expect(rec.manifestEntry).toEqual({ files: [], name: "Skill Capability", sys_id: ID_META_ONLY });
478
+ expect(rec.warnings.join("\n")).toMatch(/metaData.json only/);
479
+ expect(rec.warnings.join("\n")).toMatch(/not in includes._tables/);
480
+ await (0, pullRecord_1.applyPull)(plan);
481
+ expect(fs.existsSync(path.join(tmpRoot, "src", SCOPE, "sys_one_extend_capability", "Skill Capability", "metaData.json"))).toBe(true);
482
+ expect(JSON.parse(readLocalManifest()).tables.sys_one_extend_capability.records["Skill Capability"].files).toEqual([]);
483
+ });
484
+ test("not in the server manifest but has a field override → override drives the file list", async function () {
485
+ mockConfig.resolveConfigForScope.mockReturnValue(Object.assign(resolvedScope(), {
486
+ fieldOverrides: { x_cadso_journey_action: { configurations: { type: "json" } } },
487
+ }));
488
+ mockClient.getMissingFiles.mockResolvedValue(bulkDownload("x_cadso_journey_action", ID_META_ONLY, "Send Email", [
489
+ { name: "configurations", type: "json", content: "{}" },
490
+ ]));
491
+ const plan = await (0, pullRecord_1.planPull)([{ table: "x_cadso_journey_action", sysId: ID_META_ONLY }]);
492
+ expect(mockClient.getMissingFiles.mock.calls[0][0]).toEqual({
493
+ x_cadso_journey_action: { [ID_META_ONLY]: [{ name: "configurations", type: "json" }] },
494
+ });
495
+ expect(plan.records[0].manifestEntry.files).toEqual([{ name: "configurations", type: "json" }]);
496
+ });
497
+ });
498
+ describe("planPull — renamed record", function () {
499
+ test("replaces the stale key, warns about the old folder", async function () {
500
+ writeLocalManifest({
501
+ tables: { sys_security_acl: { records: {
502
+ OldName: { files: [{ name: "script", type: "js" }], name: "OldName", sys_id: ID_NEW },
503
+ } } },
504
+ scope: SCOPE,
505
+ }, false);
506
+ const plan = await (0, pullRecord_1.planPull)([{ table: "sys_security_acl", sysId: ID_NEW }]);
507
+ expect(plan.records[0].previousKey).toBe("OldName");
508
+ expect(plan.records[0].manifestAction).toBe("update");
509
+ expect(plan.records[0].warnings.join("\n")).toMatch(/renamed on the instance/);
510
+ await (0, pullRecord_1.applyPull)(plan);
511
+ expect(Object.keys(JSON.parse(readLocalManifest()).tables.sys_security_acl.records)).toEqual([ID_NEW]);
512
+ });
513
+ test("a manifest Dovetail did not write is flagged for formatting churn", async function () {
514
+ fs.writeFileSync(path.join(tmpRoot, "dove.manifest." + SCOPE + ".json"), JSON.stringify(localManifestWithSibling(), null, "\t"));
515
+ const plan = await (0, pullRecord_1.planPull)([{ table: "sys_security_acl", sysId: ID_NEW }]);
516
+ expect(plan.manifests[0].formatPreserved).toBe(false);
517
+ expect(plan.warnings.join("\n")).toMatch(/formatting differs/);
518
+ });
519
+ });
520
+ // ---------- --from-update-set ----------
521
+ describe("targetsFromUpdateSet", function () {
522
+ const SET = "d".repeat(32);
523
+ test("expands per-record captures, skips schema and DELETE captures, pages", async function () {
524
+ const page1 = [];
525
+ for (let i = 0; i < 1000; i++) {
526
+ page1.push({ sys_id: "x", name: "sys_script_include_" + ("0".repeat(28) + String(1000 + i)).slice(-32), action: "INSERT_OR_UPDATE" });
527
+ }
528
+ const page2 = [
529
+ { sys_id: "y1", name: "sys_security_acl_" + ID_NEW, action: "INSERT_OR_UPDATE" },
530
+ { sys_id: "y2", name: "sys_security_acl_" + ID_NEW, action: "INSERT_OR_UPDATE" }, // duplicate capture
531
+ { sys_id: "y3", name: "sys_dictionary_x_cadso_journey_run_status", action: "INSERT_OR_UPDATE" },
532
+ { sys_id: "y4", name: "sys_script_include_" + ID_SIB, action: "DELETE" },
533
+ ];
534
+ mockClient.getUpdateSetMembers
535
+ .mockResolvedValueOnce(page1)
536
+ .mockResolvedValueOnce(page2);
537
+ const out = await (0, pullRecord_1.targetsFromUpdateSet)(SET);
538
+ expect(mockClient.getUpdateSetMembers).toHaveBeenCalledTimes(2);
539
+ expect(mockClient.getUpdateSetMembers.mock.calls[1][1]).toBe(1000); // offset paged
540
+ expect(out.targets).toHaveLength(1001);
541
+ expect(out.targets[1000]).toEqual({ table: "sys_security_acl", sysId: ID_NEW });
542
+ expect(out.skipped).toEqual([
543
+ "sys_dictionary_x_cadso_journey_run_status (not a per-record capture)",
544
+ "sys_script_include_" + ID_SIB + " (DELETE)",
545
+ ]);
546
+ });
547
+ test("empty set / unknown sys_id → error", async function () {
548
+ mockClient.getUpdateSetMembers.mockResolvedValue([]);
549
+ await expect((0, pullRecord_1.targetsFromUpdateSet)(SET)).rejects.toThrow(/no customer updates found/);
550
+ });
551
+ test("malformed sys_id → error before any read", async function () {
552
+ await expect((0, pullRecord_1.targetsFromUpdateSet)("nope")).rejects.toThrow(/not a sys_id/);
553
+ expect(mockClient.getUpdateSetMembers).not.toHaveBeenCalled();
554
+ });
555
+ });
556
+ // ---------- CLI surface ----------
557
+ describe("pullRecordCommand", function () {
558
+ const base = { logLevel: "warn" };
559
+ test("collectTargets composes positional + --sys-ids + --from-update-set", async function () {
560
+ mockClient.getUpdateSetMembers.mockResolvedValue([
561
+ { sys_id: "y", name: "sys_script_include_" + ID_SIB, action: "INSERT_OR_UPDATE" },
562
+ ]);
563
+ const out = await (0, pullRecordCommand_1.collectTargets)(Object.assign({}, base, {
564
+ table: "sys_security_acl",
565
+ sysId: [ID_NEW],
566
+ "sys-ids": ID_OTHER_SCOPE,
567
+ fromUpdateSet: "d".repeat(32),
568
+ }));
569
+ expect(out.targets).toEqual([
570
+ { table: "sys_security_acl", sysId: ID_NEW },
571
+ { table: "sys_security_acl", sysId: ID_OTHER_SCOPE },
572
+ { table: "sys_script_include", sysId: ID_SIB },
573
+ ]);
574
+ });
575
+ test("a table with no sys_id is an error, not a silent no-op", async function () {
576
+ await expect((0, pullRecordCommand_1.collectTargets)(Object.assign({}, base, { table: "sys_security_acl" })))
577
+ .rejects.toThrow(/give at least one sys_id/);
578
+ });
579
+ test("a stray -t (the refresh spelling) is an error, not a silent no-op", async function () {
580
+ await (0, pullRecordCommand_1.pullRecordCommand)(Object.assign({}, base, { t: "sys_security_acl", fromUpdateSet: "d".repeat(32) }));
581
+ expect(process.exitCode).toBe(1);
582
+ expect(String(mockLogger.error.mock.calls[0][0])).toMatch(/has no -t\/--table flag/);
583
+ expect(mockClient.getUpdateSetMembers).not.toHaveBeenCalled();
584
+ });
585
+ test("--dry-run plans, reports, and writes nothing", async function () {
586
+ const before = snapshot(tmpRoot);
587
+ await (0, pullRecordCommand_1.pullRecordCommand)(Object.assign({}, base, { table: "sys_security_acl", sysId: [ID_NEW], dryRun: true }));
588
+ expect(process.exitCode).toBeUndefined();
589
+ expect(snapshot(tmpRoot)).toEqual(before);
590
+ const lines = mockLogger.info.mock.calls.map(function (c) { return String(c[0]); }).join("\n");
591
+ expect(lines).toMatch(/Dry run/);
592
+ expect(lines).toMatch(/script\.js/);
593
+ expect(lines).toMatch(/metaData\.json/);
594
+ expect(lines).toMatch(/manifest key "94243ee5c3f78f10d4ddf1db050131a6"/);
595
+ expect(lines).toMatch(/would splice 1 entry/);
596
+ });
597
+ test("live run writes and reports success", async function () {
598
+ await (0, pullRecordCommand_1.pullRecordCommand)(Object.assign({}, base, { table: "sys_security_acl", sysId: [ID_NEW] }));
599
+ expect(process.exitCode).toBeUndefined();
600
+ expect(String(mockLogger.success.mock.calls[0][0])).toMatch(/Pulled 1 record\(s\): 2 file\(s\) written/);
601
+ expect(fs.existsSync(path.join(tmpRoot, "src", SCOPE, "sys_security_acl", ID_NEW, "script.js"))).toBe(true);
602
+ });
603
+ test("a refusal exits non-zero with the message", async function () {
604
+ mockClient.getRecordScope.mockImplementation(scopeRead("x_cadso_core"));
605
+ await (0, pullRecordCommand_1.pullRecordCommand)(Object.assign({}, base, { table: "sys_security_acl", sysId: [ID_NEW], scope: SCOPE }));
606
+ expect(process.exitCode).toBe(1);
607
+ expect(String(mockLogger.error.mock.calls[0][0])).toMatch(/belongs to scope 'x_cadso_core'/);
608
+ });
609
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tenonhq/dovetail-core",
3
- "version": "0.0.124",
3
+ "version": "0.0.125",
4
4
  "description": "Next-gen file syncer",
5
5
  "license": "GPL-3.0",
6
6
  "main": "./dist/index.js",