@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.
- package/dist/allScopesCommands.js +15 -8
- package/dist/appUtils.js +174 -47
- package/dist/commander.js +12 -0
- package/dist/commands.js +9 -0
- package/dist/createRecordCommand.js +26 -13
- 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/syncManifestRecord.test.js +234 -0
- package/dist/tests/syncManifestRefresh.test.js +159 -0
- package/package.json +1 -1
- package/release-manifest.json +592 -635
|
@@ -106,8 +106,17 @@ async function processManifestForScope(manifest, sourceDirectory, forceWrite = f
|
|
|
106
106
|
await fsp.mkdir(recordPath, { recursive: true });
|
|
107
107
|
// Process each file in the record
|
|
108
108
|
for (const file of record.files || []) {
|
|
109
|
-
|
|
110
|
-
|
|
109
|
+
// Normalize metaData.json through the shared stamper before it hits
|
|
110
|
+
// disk. Without this the all-scopes path persisted the server's raw
|
|
111
|
+
// metadata — an absolute https://<instance>.service-now.com
|
|
112
|
+
// _record_link plus every field's live display_value — so a record
|
|
113
|
+
// written here churned against the same record written by `refresh`,
|
|
114
|
+
// and re-pulling from a different instance rewrote every file.
|
|
115
|
+
// stampMetadataContent is a no-op on non-metaData files and idempotent
|
|
116
|
+
// on already-normalized content.
|
|
117
|
+
const stamped = AppUtils.stampMetadataContent(file);
|
|
118
|
+
const filePath = path.join(recordPath, `${stamped.name}.${stamped.type}`);
|
|
119
|
+
const fileContent = stamped.content || "";
|
|
111
120
|
const fileDir = path.dirname(filePath);
|
|
112
121
|
await fsp.mkdir(fileDir, { recursive: true });
|
|
113
122
|
try {
|
|
@@ -118,15 +127,13 @@ async function processManifestForScope(manifest, sourceDirectory, forceWrite = f
|
|
|
118
127
|
throw writeError;
|
|
119
128
|
}
|
|
120
129
|
}
|
|
121
|
-
// If no metadata from server,
|
|
130
|
+
// If no metadata from server, write the deterministic placeholder. The
|
|
131
|
+
// previous stub stamped `_generatedAt: new Date().toISOString()`, which
|
|
132
|
+
// rewrote the file on every single run and read as a change downstream.
|
|
122
133
|
if (!hasMetadataFromServer) {
|
|
123
134
|
const metadataFilePath = path.join(recordPath, "metaData.json");
|
|
124
|
-
const metadataContent = {
|
|
125
|
-
_generatedAt: new Date().toISOString(),
|
|
126
|
-
_note: "Generated locally - metadata not provided by server"
|
|
127
|
-
};
|
|
128
135
|
try {
|
|
129
|
-
await fsp.writeFile(metadataFilePath,
|
|
136
|
+
await fsp.writeFile(metadataFilePath, AppUtils.EMPTY_METADATA_CONTENT, "utf8");
|
|
130
137
|
}
|
|
131
138
|
catch (metaError) {
|
|
132
139
|
Logger_1.logger.error("Failed to write metadata: " + metadataFilePath);
|
package/dist/appUtils.js
CHANGED
|
@@ -36,7 +36,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
36
36
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
37
37
|
};
|
|
38
38
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
|
-
exports.createAndAssignUpdateSet = exports.swapScope = exports.buildFiles = exports.summarizeRecord = exports.pushFiles = exports.getAppFileList = exports.groupAppFiles = exports.processMissingFiles = exports.refreshAllFiles = exports.findMissingFiles = exports.syncManifest = exports.narrowManifestToTables = exports.processManifest = exports.normalizeManifestKeys = exports.toSafeFolderName = exports.stampMetadataContent = void 0;
|
|
39
|
+
exports.createAndAssignUpdateSet = exports.swapScope = exports.buildFiles = exports.summarizeRecord = exports.pushFiles = exports.getAppFileList = exports.groupAppFiles = exports.processMissingFiles = exports.refreshAllFiles = exports.findMissingFiles = exports.syncManifest = exports.narrowManifestToRecord = exports.narrowManifestToTables = exports.processManifest = exports.normalizeManifestKeys = exports.toSafeFolderName = exports.stampMetadataContent = exports.emptyMetadataFile = exports.EMPTY_METADATA_CONTENT = void 0;
|
|
40
40
|
const path_1 = __importDefault(require("path"));
|
|
41
41
|
const progress_1 = __importDefault(require("progress"));
|
|
42
42
|
const fUtils = __importStar(require("./FileUtils"));
|
|
@@ -81,24 +81,49 @@ const stripFieldDisplayValues = (metadata) => {
|
|
|
81
81
|
}
|
|
82
82
|
}
|
|
83
83
|
};
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
//
|
|
84
|
+
// The deterministic placeholder written when the server returns a record with no
|
|
85
|
+
// metadata at all. Every record directory still gets a metaData.json, but the
|
|
86
|
+
// file carries no generation-time value — the previous stub stamped
|
|
87
|
+
// `new Date().toISOString()`, so every run rewrote it and the record read as
|
|
88
|
+
// changed on a pull that changed nothing.
|
|
89
|
+
exports.EMPTY_METADATA_CONTENT = JSON.stringify({
|
|
90
|
+
_localOnly: true,
|
|
91
|
+
_description: "No metadata provided by server",
|
|
92
|
+
}, null, 2);
|
|
93
|
+
const emptyMetadataFile = () => ({
|
|
94
|
+
name: "metaData",
|
|
95
|
+
type: "json",
|
|
96
|
+
content: exports.EMPTY_METADATA_CONTENT,
|
|
97
|
+
});
|
|
98
|
+
exports.emptyMetadataFile = emptyMetadataFile;
|
|
99
|
+
// Finalize a record's metaData.json so its on-disk bytes are a pure function of
|
|
100
|
+
// the record — same record, same file, on any instance, from any machine, on
|
|
101
|
+
// every write path. Three things are normalized:
|
|
102
|
+
// 1. `_record_link` loses its instance host (stripRecordLinkHost).
|
|
103
|
+
// 2. Every field loses its live `display_value` (stripFieldDisplayValues).
|
|
104
|
+
// 3. `_lastUpdatedOn` is DELETED, not rewritten. It duplicated
|
|
105
|
+
// `sys_updated_on.value` exactly, and its no-sys_updated_on fallback was a
|
|
106
|
+
// wall-clock stamp that churned on every pull. Its only reader,
|
|
107
|
+
// extractUpdatedOn (reconcile/recordSource.ts), already prefers
|
|
108
|
+
// `sys_updated_on` and tolerates its absence.
|
|
109
|
+
// All record fields (sys_id, sys_scope, the value pairs) are preserved, so the
|
|
110
|
+
// file stays a full snapshot of the record rather than a stub.
|
|
87
111
|
const stampMetadataContent = (file) => {
|
|
88
112
|
if (file.name !== "metaData" || file.type !== "json")
|
|
89
113
|
return file;
|
|
90
|
-
const stamp = new Date().toISOString();
|
|
91
114
|
if (!file.content) {
|
|
92
|
-
return { ...file, content:
|
|
115
|
+
return { ...file, content: exports.EMPTY_METADATA_CONTENT };
|
|
93
116
|
}
|
|
94
117
|
try {
|
|
95
118
|
const metadata = JSON.parse(file.content);
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
metadata._lastUpdatedOn = stamp;
|
|
119
|
+
// Guard against non-object JSON (a bare string/number/array parses fine but
|
|
120
|
+
// has no keys to normalize) — writing it back verbatim is the safe default.
|
|
121
|
+
if (typeof metadata !== "object" || metadata === null || Array.isArray(metadata)) {
|
|
122
|
+
return file;
|
|
101
123
|
}
|
|
124
|
+
// Drop the key outright, including from legacy content that already carries
|
|
125
|
+
// it, so a re-stamp settles the file instead of preserving the stale stamp.
|
|
126
|
+
delete metadata._lastUpdatedOn;
|
|
102
127
|
if (typeof metadata._record_link === "string") {
|
|
103
128
|
metadata._record_link = stripRecordLinkHost(metadata._record_link);
|
|
104
129
|
}
|
|
@@ -115,15 +140,10 @@ const hasServerMetadata = (files) => files.some((f) => f.name === "metaData" &&
|
|
|
115
140
|
const processFilesInManRec = async (recPath, rec, forceWrite) => {
|
|
116
141
|
FileLogger_1.fileLogger.debug("Processing record: " + rec.name + " (" + rec.files.length + " files)");
|
|
117
142
|
const fileWrite = fUtils.writeSNFileCurry(forceWrite);
|
|
118
|
-
// If the server did not provide a metadata file, fall back to a
|
|
143
|
+
// If the server did not provide a metadata file, fall back to a deterministic
|
|
119
144
|
// stub so the record directory always has a metaData.json.
|
|
120
145
|
if (!hasServerMetadata(rec.files)) {
|
|
121
|
-
|
|
122
|
-
name: "metaData",
|
|
123
|
-
type: "json",
|
|
124
|
-
content: JSON.stringify({ _lastUpdatedOn: new Date().toISOString() }, null, 2),
|
|
125
|
-
};
|
|
126
|
-
await fileWrite(stubMetadata, recPath);
|
|
146
|
+
await fileWrite((0, exports.emptyMetadataFile)(), recPath);
|
|
127
147
|
}
|
|
128
148
|
const writeResults = await (0, genericUtils_1.allSettledBatched)(rec.files, constants_1.CONCURRENCY_FILES, function (file) { return fileWrite((0, exports.stampMetadataContent)(file), recPath); });
|
|
129
149
|
const writeFailures = writeResults.filter((r) => r.status === "rejected");
|
|
@@ -298,6 +318,44 @@ const narrowManifestToTables = (manifest, tables) => {
|
|
|
298
318
|
return Object.assign({}, manifest, { tables: wantedTables });
|
|
299
319
|
};
|
|
300
320
|
exports.narrowManifestToTables = narrowManifestToTables;
|
|
321
|
+
/**
|
|
322
|
+
* Narrow a manifest's table map to a SINGLE record (matched by `table` +
|
|
323
|
+
* `sysId`), for the FILE refresh only.
|
|
324
|
+
*
|
|
325
|
+
* Returns a NEW manifest and NEVER mutates its input — same safety contract as
|
|
326
|
+
* `narrowManifestToTables`: the caller keeps writing the original, full manifest
|
|
327
|
+
* to disk, so the on-disk `dove.manifest.<scope>.json` never loses its other
|
|
328
|
+
* tables/records. Only the file download is scoped to this one record.
|
|
329
|
+
*
|
|
330
|
+
* If the table or a record with that sys_id is absent from the manifest, the
|
|
331
|
+
* returned manifest has an empty table map — refreshAllFiles no-ops on it, so no
|
|
332
|
+
* files are written (the create flow warns and points the user at `dove refresh`).
|
|
333
|
+
*/
|
|
334
|
+
const narrowManifestToRecord = (manifest, table, sysId) => {
|
|
335
|
+
var empty = Object.assign({}, manifest, { tables: {} });
|
|
336
|
+
var tables = manifest.tables || {};
|
|
337
|
+
var tableEntry = tables[table];
|
|
338
|
+
if (!tableEntry || !tableEntry.records)
|
|
339
|
+
return empty;
|
|
340
|
+
var records = tableEntry.records;
|
|
341
|
+
var recKeys = Object.keys(records);
|
|
342
|
+
var wantedRecords = {};
|
|
343
|
+
// sys_id is unique per table, so stop at the first match — the record set for
|
|
344
|
+
// a large table can be sizable and this is the exact scenario the feature targets.
|
|
345
|
+
for (var i = 0; i < recKeys.length; i++) {
|
|
346
|
+
var rec = records[recKeys[i]];
|
|
347
|
+
if (rec && rec.sys_id === sysId) {
|
|
348
|
+
wantedRecords[recKeys[i]] = rec;
|
|
349
|
+
break;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
if (Object.keys(wantedRecords).length === 0)
|
|
353
|
+
return empty;
|
|
354
|
+
var narrowedTables = {};
|
|
355
|
+
narrowedTables[table] = Object.assign({}, tableEntry, { records: wantedRecords });
|
|
356
|
+
return Object.assign({}, manifest, { tables: narrowedTables });
|
|
357
|
+
};
|
|
358
|
+
exports.narrowManifestToRecord = narrowManifestToRecord;
|
|
301
359
|
const syncManifest = async (scope, options = {}) => {
|
|
302
360
|
// Top-level entry owns the collector lifecycle. Recursive calls (all-scopes
|
|
303
361
|
// → per-scope) inherit the collector via options._benchmarkCollector.
|
|
@@ -368,6 +426,23 @@ const syncManifest = async (scope, options = {}) => {
|
|
|
368
426
|
// so intersecting the manifest is enough. `newManifest` is left intact —
|
|
369
427
|
// see narrowManifestToTables.
|
|
370
428
|
var refreshManifest = (0, exports.narrowManifestToTables)(newManifest, options.tables);
|
|
429
|
+
// Single-record narrowing (dove create round-trip). Applied after the
|
|
430
|
+
// --table narrowing so the two compose; the full manifest was already
|
|
431
|
+
// written above, so only the file download is scoped to the one record.
|
|
432
|
+
if (options.record) {
|
|
433
|
+
refreshManifest = (0, exports.narrowManifestToRecord)(refreshManifest, options.record.table, options.record.sysId);
|
|
434
|
+
if (Object.keys(refreshManifest.tables).length === 0) {
|
|
435
|
+
Logger_1.logger.warn("Created record " + options.record.sysId + " (" + options.record.table +
|
|
436
|
+
") not found in the refreshed scope manifest — local files not written. " +
|
|
437
|
+
"If '" + options.record.table + "' is missing from this scope's `_tables` " +
|
|
438
|
+
"whitelist in dove.config.js, add it there first; otherwise run " +
|
|
439
|
+
"'npx dove refresh' to pull it.");
|
|
440
|
+
}
|
|
441
|
+
else {
|
|
442
|
+
FileLogger_1.fileLogger.debug("syncManifest: narrowed file refresh to single record " +
|
|
443
|
+
options.record.sysId + " (" + options.record.table + ")");
|
|
444
|
+
}
|
|
445
|
+
}
|
|
371
446
|
if (options.tables && options.tables.length > 0) {
|
|
372
447
|
var wantedNames = Object.keys(refreshManifest.tables);
|
|
373
448
|
if (wantedNames.length === 0) {
|
|
@@ -385,10 +460,14 @@ const syncManifest = async (scope, options = {}) => {
|
|
|
385
460
|
collector.startScope(scope);
|
|
386
461
|
await (0, exports.refreshAllFiles)(refreshManifest, scopeSourcePath, {
|
|
387
462
|
force: options.force,
|
|
463
|
+
metadataOnly: options.metadataOnly,
|
|
388
464
|
benchmarkCollector: collector,
|
|
389
465
|
});
|
|
390
|
-
// Update the in-memory manifest for this scope
|
|
391
|
-
|
|
466
|
+
// Update the in-memory manifest for this scope. Skipped under
|
|
467
|
+
// metadataOnly: persisting the manifest would put record adds, drops and
|
|
468
|
+
// renames into a diff that is supposed to contain metaData.json only. A
|
|
469
|
+
// normal `dove refresh` is what reconciles the manifest.
|
|
470
|
+
if (!options.metadataOnly && ConfigManager.isMultiScopeManifest(curManifest)) {
|
|
392
471
|
curManifest[scope] = newManifest;
|
|
393
472
|
ConfigManager.updateManifest(curManifest);
|
|
394
473
|
}
|
|
@@ -399,6 +478,7 @@ const syncManifest = async (scope, options = {}) => {
|
|
|
399
478
|
// scopes that leaked in before the whitelist gate existed.
|
|
400
479
|
var childOptions = {
|
|
401
480
|
force: options.force,
|
|
481
|
+
metadataOnly: options.metadataOnly,
|
|
402
482
|
tables: options.tables,
|
|
403
483
|
_benchmarkCollector: collector,
|
|
404
484
|
};
|
|
@@ -547,6 +627,13 @@ const buildAllFilesMap = (manifest) => {
|
|
|
547
627
|
*
|
|
548
628
|
* @param options.force — when true, always overwrite local files even if their
|
|
549
629
|
* content matches the instance. Use for deliberate "reset local to instance".
|
|
630
|
+
*
|
|
631
|
+
* @param options.metadataOnly — when true, rewrite every existing record's
|
|
632
|
+
* metaData.json and touch nothing else: no field file is written, and a record
|
|
633
|
+
* with no directory on disk is skipped entirely (writing one would leave an
|
|
634
|
+
* orphan directory holding a lone metaData.json and no fields). Use to settle a
|
|
635
|
+
* mirror after a change to the metadata writer, without the source churn
|
|
636
|
+
* `--force` produces.
|
|
550
637
|
*/
|
|
551
638
|
const refreshAllFiles = async (newManifest, sourcePath, options = {}) => {
|
|
552
639
|
try {
|
|
@@ -554,7 +641,8 @@ const refreshAllFiles = async (newManifest, sourcePath, options = {}) => {
|
|
|
554
641
|
const tableNames = Object.keys(allFiles);
|
|
555
642
|
if (tableNames.length === 0)
|
|
556
643
|
return;
|
|
557
|
-
FileLogger_1.fileLogger.debug("Refreshing file content for " + tableNames.length + " tables (force=" + !!options.force +
|
|
644
|
+
FileLogger_1.fileLogger.debug("Refreshing file content for " + tableNames.length + " tables (force=" + !!options.force +
|
|
645
|
+
", metadataOnly=" + !!options.metadataOnly + ")");
|
|
558
646
|
const { tableOptions = {} } = ConfigManager.getConfig();
|
|
559
647
|
const client = (0, snClient_1.defaultClient)();
|
|
560
648
|
const totalChunks = Math.ceil(tableNames.length / BULK_DOWNLOAD_TABLE_CHUNK_SIZE);
|
|
@@ -581,27 +669,55 @@ const refreshAllFiles = async (newManifest, sourcePath, options = {}) => {
|
|
|
581
669
|
});
|
|
582
670
|
var writtenCount = 0;
|
|
583
671
|
var unchangedCount = 0;
|
|
672
|
+
var skippedAbsentCount = 0;
|
|
673
|
+
var metadataWrittenCount = 0;
|
|
584
674
|
const forceWrite = !!options.force;
|
|
675
|
+
const metadataOnly = !!options.metadataOnly;
|
|
585
676
|
const forceWriter = fUtils.writeSNFileCurry(false);
|
|
586
677
|
const processedTableNames = Object.keys(filesToProcess);
|
|
587
678
|
await (0, genericUtils_1.processBatched)(processedTableNames, constants_1.CONCURRENCY_TABLES, async function (tableName) {
|
|
588
679
|
var tablePath = path_1.default.join(basePath, tableName);
|
|
589
680
|
var recs = filesToProcess[tableName].records;
|
|
590
681
|
var recKeys = Object.keys(recs);
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
682
|
+
// metadataOnly never materializes a record directory — it refreshes the
|
|
683
|
+
// metadata of records already on disk. Creating dirs here would leave an
|
|
684
|
+
// orphan folder holding a lone metaData.json with no field files.
|
|
685
|
+
if (!metadataOnly) {
|
|
686
|
+
await Promise.all(recKeys.map(function (k) {
|
|
687
|
+
// toSafeFolderName, not raw recs[k].name: the bulkDownload response
|
|
688
|
+
// carries the unmodified ServiceNow display name, which may be
|
|
689
|
+
// filesystem-unsafe (e.g. a `<table>.*` ACL). Without this, refresh
|
|
690
|
+
// recreates Windows-illegal folders that normalizeManifestKeys
|
|
691
|
+
// already removed from the manifest.
|
|
692
|
+
return fUtils.createDirRecursively(path_1.default.join(tablePath, (0, exports.toSafeFolderName)(recs[k])));
|
|
693
|
+
}));
|
|
694
|
+
}
|
|
599
695
|
await (0, genericUtils_1.processBatched)(recKeys, constants_1.CONCURRENCY_RECORDS, async function (recKey) {
|
|
600
696
|
var rec = recs[recKey];
|
|
601
697
|
var recPath = path_1.default.join(tablePath, (0, exports.toSafeFolderName)(rec));
|
|
698
|
+
// metadataOnly refreshes what is already checked out. A record the
|
|
699
|
+
// instance has but this branch does not is left alone — pulling it in
|
|
700
|
+
// would be a content change, which is exactly what this mode excludes.
|
|
701
|
+
// pathExists, NOT isDirectory: isDirectory stats unguarded and REJECTS
|
|
702
|
+
// on ENOENT, and processBatched propagates, so a single absent record
|
|
703
|
+
// would abort the whole scope — the common case, since "the instance
|
|
704
|
+
// has records this branch doesn't" is precisely what we're skipping.
|
|
705
|
+
if (metadataOnly && !(await fUtils.pathExists(recPath))) {
|
|
706
|
+
skippedAbsentCount++;
|
|
707
|
+
// Drop the downloaded content for skipped records too — filesToProcess
|
|
708
|
+
// accumulates across every chunk, so holding it would grow unbounded
|
|
709
|
+
// across a large scope.
|
|
710
|
+
rec.files = rec.files.map(function (file) {
|
|
711
|
+
var copy = Object.assign({}, file);
|
|
712
|
+
delete copy.content;
|
|
713
|
+
return copy;
|
|
714
|
+
});
|
|
715
|
+
progress.tick();
|
|
716
|
+
return;
|
|
717
|
+
}
|
|
602
718
|
// Split server-provided metadata off from the regular files so we can
|
|
603
719
|
// track whether any regular file actually changed — metaData shouldn't
|
|
604
|
-
// be the trigger for "this record changed" since we
|
|
720
|
+
// be the trigger for "this record changed" since we rewrite it on every
|
|
605
721
|
// touch.
|
|
606
722
|
var metadataFiles = [];
|
|
607
723
|
var regularFiles = [];
|
|
@@ -614,13 +730,17 @@ const refreshAllFiles = async (newManifest, sourcePath, options = {}) => {
|
|
|
614
730
|
regularFiles.push(rf);
|
|
615
731
|
}
|
|
616
732
|
}
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
733
|
+
// metadataOnly writes no field file at all, so there is nothing to
|
|
734
|
+
// diff and the download result is discarded for regular files.
|
|
735
|
+
var results = metadataOnly
|
|
736
|
+
? []
|
|
737
|
+
: await (0, genericUtils_1.allSettledBatched)(regularFiles, constants_1.CONCURRENCY_FILES, async function (file) {
|
|
738
|
+
if (forceWrite) {
|
|
739
|
+
await forceWriter(file, recPath);
|
|
740
|
+
return true;
|
|
741
|
+
}
|
|
742
|
+
return fUtils.writeSNFileIfDifferent(file, recPath);
|
|
743
|
+
});
|
|
624
744
|
var anyChanged = false;
|
|
625
745
|
for (var f = 0; f < results.length; f++) {
|
|
626
746
|
var res = results[f];
|
|
@@ -637,23 +757,21 @@ const refreshAllFiles = async (newManifest, sourcePath, options = {}) => {
|
|
|
637
757
|
}
|
|
638
758
|
}
|
|
639
759
|
// Only touch metaData when at least one regular file in the record
|
|
640
|
-
// actually changed
|
|
641
|
-
//
|
|
760
|
+
// actually changed — otherwise a record already in sync with the
|
|
761
|
+
// instance would be rewritten for nothing. `force` and `metadataOnly`
|
|
762
|
+
// both mean "rewrite it regardless". Prefer the server-provided
|
|
642
763
|
// metadata (full field snapshot) over a stub; fall back to a stub only
|
|
643
764
|
// when the server didn't send metadata at all.
|
|
644
|
-
if (anyChanged || forceWrite) {
|
|
765
|
+
if (anyChanged || forceWrite || metadataOnly) {
|
|
645
766
|
let metadataFile;
|
|
646
767
|
if (metadataFiles.length > 0 && metadataFiles[0].content) {
|
|
647
768
|
metadataFile = (0, exports.stampMetadataContent)(metadataFiles[0]);
|
|
648
769
|
}
|
|
649
770
|
else {
|
|
650
|
-
metadataFile =
|
|
651
|
-
name: "metaData",
|
|
652
|
-
type: "json",
|
|
653
|
-
content: JSON.stringify({ _lastUpdatedOn: new Date().toISOString() }, null, 2),
|
|
654
|
-
};
|
|
771
|
+
metadataFile = (0, exports.emptyMetadataFile)();
|
|
655
772
|
}
|
|
656
773
|
await forceWriter(metadataFile, recPath);
|
|
774
|
+
metadataWrittenCount++;
|
|
657
775
|
}
|
|
658
776
|
// Strip content from manifest entries to keep memory bounded.
|
|
659
777
|
rec.files = rec.files.map(function (file) {
|
|
@@ -664,8 +782,17 @@ const refreshAllFiles = async (newManifest, sourcePath, options = {}) => {
|
|
|
664
782
|
progress.tick();
|
|
665
783
|
});
|
|
666
784
|
});
|
|
667
|
-
FileLogger_1.fileLogger.debug("Refresh complete: " + writtenCount + " written, " + unchangedCount + " unchanged"
|
|
668
|
-
|
|
785
|
+
FileLogger_1.fileLogger.debug("Refresh complete: " + writtenCount + " written, " + unchangedCount + " unchanged, " +
|
|
786
|
+
metadataWrittenCount + " metaData written, " + skippedAbsentCount + " records absent locally");
|
|
787
|
+
if (metadataOnly) {
|
|
788
|
+
// writtenCount is always 0 here — no field file is written — so report the
|
|
789
|
+
// metadata counts instead of the misleading "no changes detected".
|
|
790
|
+
Logger_1.logger.info("Rewrote metaData.json for " + metadataWrittenCount + " record(s)" +
|
|
791
|
+
(skippedAbsentCount > 0
|
|
792
|
+
? " (" + skippedAbsentCount + " skipped — not checked out locally)"
|
|
793
|
+
: ""));
|
|
794
|
+
}
|
|
795
|
+
else if (writtenCount > 0) {
|
|
669
796
|
Logger_1.logger.info("Refreshed " + writtenCount + " file(s) from instance" +
|
|
670
797
|
(unchangedCount > 0 ? " (" + unchangedCount + " already in sync)" : ""));
|
|
671
798
|
}
|
package/dist/commander.js
CHANGED
|
@@ -89,6 +89,13 @@ function configureCli(cli) {
|
|
|
89
89
|
default: false,
|
|
90
90
|
describe: "Overwrite local files even when content matches the instance",
|
|
91
91
|
},
|
|
92
|
+
"metadata-only": {
|
|
93
|
+
type: "boolean",
|
|
94
|
+
default: false,
|
|
95
|
+
describe: "Rewrite only each record's metaData.json — no field file is written, no " +
|
|
96
|
+
"record directory is created, and the manifest is left untouched. Records " +
|
|
97
|
+
"not already checked out locally are skipped. Cannot be combined with --force.",
|
|
98
|
+
},
|
|
92
99
|
scope: {
|
|
93
100
|
alias: "s",
|
|
94
101
|
type: "string",
|
|
@@ -228,6 +235,11 @@ function configureCli(cli) {
|
|
|
228
235
|
default: false,
|
|
229
236
|
describe: "Skip interactive prompts",
|
|
230
237
|
},
|
|
238
|
+
refresh: {
|
|
239
|
+
type: "boolean",
|
|
240
|
+
default: true,
|
|
241
|
+
describe: "Pull the created record's files to disk afterward (default: true; --no-refresh skips the local sync)",
|
|
242
|
+
},
|
|
231
243
|
});
|
|
232
244
|
return cmdArgs;
|
|
233
245
|
}, createRecordCommand_1.createRecordCommand)
|
package/dist/commands.js
CHANGED
|
@@ -123,11 +123,20 @@ async function refreshCommand(args, log = true) {
|
|
|
123
123
|
"Add " + pronoun + " to includes._tables (or includes._scopes.<scope>._tables) in dove.config.js first.");
|
|
124
124
|
}
|
|
125
125
|
}
|
|
126
|
+
// --force overwrites field files from the instance; --metadata-only writes
|
|
127
|
+
// no field file at all. Asking for both is contradictory, so refuse rather
|
|
128
|
+
// than silently picking one and clobbering the caller's source.
|
|
129
|
+
if (args.metadataOnly && args.force) {
|
|
130
|
+
throw new Error("--metadata-only cannot be combined with --force: --force overwrites every " +
|
|
131
|
+
"field file from the instance, while --metadata-only writes none. Pick one.");
|
|
132
|
+
}
|
|
126
133
|
FileLogger_1.fileLogger.debug("Syncing manifest from instance (force=" + !!args.force +
|
|
134
|
+
", metadataOnly=" + !!args.metadataOnly +
|
|
127
135
|
", benchmark=" + !!args.benchmark +
|
|
128
136
|
", tables=" + (tables.length > 0 ? tables.join(",") : "all") + ")");
|
|
129
137
|
await AppUtils.syncManifest(args.scope, {
|
|
130
138
|
force: !!args.force,
|
|
139
|
+
metadataOnly: !!args.metadataOnly,
|
|
131
140
|
benchmark: !!args.benchmark,
|
|
132
141
|
tables: tables.length > 0 ? tables : undefined,
|
|
133
142
|
});
|
|
@@ -281,20 +281,33 @@ async function createRecordCommand(args) {
|
|
|
281
281
|
" (" +
|
|
282
282
|
newSysId +
|
|
283
283
|
")");
|
|
284
|
-
// 7.
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
var sourcePath = ConfigManager.getSourcePath();
|
|
290
|
-
var localPath = path_1.default.join(sourcePath, table, recordName);
|
|
291
|
-
Logger_1.logger.success(chalk_1.default.green("Local files created at: ") + localPath);
|
|
284
|
+
// 7. Round-trip: pull ONLY the new record's files back locally. Scoped to
|
|
285
|
+
// { table, sysId } so creating one record no longer refreshes the entire
|
|
286
|
+
// scope (the whole-scope-churn footgun). --no-refresh skips it entirely.
|
|
287
|
+
if (typedArgs.refresh === false) {
|
|
288
|
+
Logger_1.logger.info("Skipping local sync (--no-refresh). Run 'npx dove refresh' to pull the record locally.");
|
|
292
289
|
}
|
|
293
|
-
|
|
294
|
-
Logger_1.logger.
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
290
|
+
else {
|
|
291
|
+
Logger_1.logger.info("Syncing record to local files...");
|
|
292
|
+
FileLogger_1.fileLogger.debug("Starting single-record sync for scope:", scope);
|
|
293
|
+
try {
|
|
294
|
+
await AppUtils.syncManifest(scope, {
|
|
295
|
+
record: { table: table, sysId: newSysId },
|
|
296
|
+
});
|
|
297
|
+
// Resolve the scope's own source directory — syncManifest writes into
|
|
298
|
+
// getSourcePathForScope(scope), which a scope config can override. Using
|
|
299
|
+
// the top-level getSourcePath() here would print a wrong path in
|
|
300
|
+
// multi-scope setups even though the files landed correctly.
|
|
301
|
+
var sourcePath = ConfigManager.getSourcePathForScope(scope);
|
|
302
|
+
var localPath = path_1.default.join(sourcePath, table, recordName);
|
|
303
|
+
Logger_1.logger.success(chalk_1.default.green("Local files created at: ") + localPath);
|
|
304
|
+
}
|
|
305
|
+
catch (syncErr) {
|
|
306
|
+
Logger_1.logger.warn("Record created on instance but local sync failed.");
|
|
307
|
+
Logger_1.logger.warn("Run 'npx dove refresh' to pull the record locally.");
|
|
308
|
+
if (syncErr instanceof Error) {
|
|
309
|
+
FileLogger_1.fileLogger.error("Sync error:", syncErr.message);
|
|
310
|
+
}
|
|
298
311
|
}
|
|
299
312
|
}
|
|
300
313
|
}
|
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
// records the {sys_id -> sys_updated_on} the instance was known to hold at the
|
|
4
4
|
// last reconcile (or refresh), namespaced by instance host. It MUST live
|
|
5
5
|
// outside the git tree: after a `git checkout`, the on-disk
|
|
6
|
-
// `metaData.
|
|
7
|
-
// developer's instance state, so the branch files cannot answer "did the
|
|
8
|
-
// change the instance since we last synced?". The baseline can.
|
|
6
|
+
// `metaData.sys_updated_on` is whatever the *committer's* instance held, not
|
|
7
|
+
// this developer's instance state, so the branch files cannot answer "did the
|
|
8
|
+
// dev change the instance since we last synced?". The baseline can.
|
|
9
9
|
//
|
|
10
10
|
// Phase 1 only READS the baseline (to disambiguate deletes and surface drift).
|
|
11
11
|
// `writeBaseline` / `baselineFromLive` are the apply-phase write path and the
|
package/dist/reconcile/fields.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
// Field-comparison rules for reconcile. A record's on-disk representation is a
|
|
3
3
|
// directory of per-field files plus a `metaData.json` snapshot. metaData is
|
|
4
|
-
// Dovetail bookkeeping — it carries
|
|
5
|
-
//
|
|
4
|
+
// Dovetail bookkeeping — it carries a host-stripped `_record_link` and a full
|
|
5
|
+
// field dump including volatile audit columns (sys_updated_on, sys_mod_count) —
|
|
6
6
|
// so including it in a content comparison would make every record read as
|
|
7
7
|
// modified. Excluding it leaves the genuine, content-bearing field files
|
|
8
8
|
// (script.js, etc.), which is exactly what `dove refresh` writes verbatim from
|
|
@@ -57,9 +57,12 @@ const fields_1 = require("./fields");
|
|
|
57
57
|
// Mirror the refresh chunk size so a large scope stays under ServiceNow's ~10 MB
|
|
58
58
|
// REST payload cap.
|
|
59
59
|
const BULK_DOWNLOAD_TABLE_CHUNK_SIZE = 5;
|
|
60
|
-
// Pull `sys_updated_on` out of a metaData.json blob.
|
|
61
|
-
// `sys_updated_on: { value }
|
|
62
|
-
//
|
|
60
|
+
// Pull `sys_updated_on` out of a metaData.json blob. Both sides carry
|
|
61
|
+
// `sys_updated_on: { value }` — that is the primary source. `_lastUpdatedOn` is
|
|
62
|
+
// a legacy fallback: Dovetail no longer writes it (it duplicated
|
|
63
|
+
// `sys_updated_on.value` and its no-sys_updated_on branch stamped a wall-clock
|
|
64
|
+
// that churned every pull), but a branch checked out before that change still
|
|
65
|
+
// has it on disk, so keep reading it. Neither present -> "".
|
|
63
66
|
function extractUpdatedOn(metaContent) {
|
|
64
67
|
if (!metaContent) {
|
|
65
68
|
return "";
|