@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.
@@ -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
- const filePath = path.join(recordPath, `${file.name}.${file.type}`);
110
- const fileContent = file.content || "";
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, create a basic one
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, JSON.stringify(metadataContent, null, 2), "utf8");
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
- // Merge _lastUpdatedOn into the server-provided metadata content. Preserves all
85
- // record fields (sys_id, sys_scope, field value/display_value pairs, etc.) so
86
- // the local metaData.json is a full snapshot of the record, not just a stub.
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: JSON.stringify({ _lastUpdatedOn: stamp }, null, 2) };
115
+ return { ...file, content: exports.EMPTY_METADATA_CONTENT };
93
116
  }
94
117
  try {
95
118
  const metadata = JSON.parse(file.content);
96
- if (metadata.sys_updated_on && metadata.sys_updated_on.value) {
97
- metadata._lastUpdatedOn = metadata.sys_updated_on.value;
98
- }
99
- else {
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 timestamp-only
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
- const stubMetadata = {
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
- if (ConfigManager.isMultiScopeManifest(curManifest)) {
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
- await Promise.all(recKeys.map(function (k) {
592
- // toSafeFolderName, not raw recs[k].name: the bulkDownload response
593
- // carries the unmodified ServiceNow display name, which may be
594
- // filesystem-unsafe (e.g. a `<table>.*` ACL). Without this, refresh
595
- // recreates Windows-illegal folders that normalizeManifestKeys
596
- // already removed from the manifest.
597
- return fUtils.createDirRecursively(path_1.default.join(tablePath, (0, exports.toSafeFolderName)(recs[k])));
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 stamp it on every
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
- var results = await (0, genericUtils_1.allSettledBatched)(regularFiles, constants_1.CONCURRENCY_FILES, async function (file) {
618
- if (forceWrite) {
619
- await forceWriter(file, recPath);
620
- return true;
621
- }
622
- return fUtils.writeSNFileIfDifferent(file, recPath);
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. Avoids rewriting _lastUpdatedOn for records that
641
- // were already in sync with the instance. Prefer the server-provided
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
- if (writtenCount > 0) {
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. Full round-trip: pull the record back locally
285
- Logger_1.logger.info("Syncing record to local files...");
286
- FileLogger_1.fileLogger.debug("Starting manifest sync for scope:", scope);
287
- try {
288
- await AppUtils.syncManifest(scope);
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
- catch (syncErr) {
294
- Logger_1.logger.warn("Record created on instance but local sync failed.");
295
- Logger_1.logger.warn("Run 'npx dove refresh' to pull the record locally.");
296
- if (syncErr instanceof Error) {
297
- FileLogger_1.fileLogger.error("Sync error:", syncErr.message);
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._lastUpdatedOn` is the *committer's* refresh time, not this
7
- // developer's instance state, so the branch files cannot answer "did the dev
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
@@ -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 `_lastUpdatedOn`, a host-stripped
5
- // `_record_link`, and a full field dump, and it is re-stamped on every touch —
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. The instance side carries
61
- // `sys_updated_on: { value }`; the on-disk side carries the stamped
62
- // `_lastUpdatedOn`. Either resolves the baseline timestamp; missing -> "".
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 "";