@tenonhq/sincronia-core 0.0.80 → 0.0.82

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/FileUtils.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.writeFileForce = exports.writeSNFileForce = exports.writeSNFileIfNotExists = exports.writeBuildFile = exports.summarizeFile = exports.encodedPathsToFilePaths = exports.isValidPath = exports.splitEncodedPaths = exports.getPathsInPath = exports.isDirectory = exports.toAbsolutePath = exports.getFileContextFromPath = exports.getFileContextWithSkipReason = exports.getBuildExt = exports.isUnderPath = exports.appendToPath = exports.pathExists = exports.createDirRecursively = exports.writeSNFileCurry = exports.writeScopeManifest = exports.writeManifestFile = exports.SNFileExists = void 0;
39
+ exports.writeFileForce = exports.writeSNFileForce = exports.writeSNFileIfNotExists = exports.writeBuildFile = exports.summarizeFile = exports.encodedPathsToFilePaths = exports.isValidPath = exports.splitEncodedPaths = exports.getPathsInPath = exports.isDirectory = exports.toAbsolutePath = exports.getFileContextFromPath = exports.getFileContextWithSkipReason = exports.getBuildExt = exports.isUnderPath = exports.appendToPath = exports.pathExists = exports.createDirRecursively = exports.writeSNFileIfDifferent = exports.writeSNFileCurry = exports.writeScopeManifest = exports.writeManifestFile = exports.SNFileExists = void 0;
40
40
  exports.writeEnvVar = writeEnvVar;
41
41
  exports.writeEnvVars = writeEnvVars;
42
42
  const constants_1 = require("./constants");
@@ -103,6 +103,45 @@ const writeSNFileCurry = (checkExists) => async (file, parentPath) => {
103
103
  }
104
104
  };
105
105
  exports.writeSNFileCurry = writeSNFileCurry;
106
+ /**
107
+ * Writes the SN file to disk only if the local content differs from `file.content`.
108
+ * Returns `true` if the file was written (missing or differed), `false` if skipped.
109
+ *
110
+ * Used by `sinc refresh` to pull ServiceNow-side edits without churning unchanged
111
+ * files (preserves mtime, avoids noisy watchers, keeps diff output clean).
112
+ */
113
+ const writeSNFileIfDifferent = async (file, parentPath) => {
114
+ let { name, type, content = "" } = file;
115
+ if (!content)
116
+ content = "";
117
+ const fullPath = path_1.default.join(parentPath, `${name}.${type}`);
118
+ let localContent = null;
119
+ try {
120
+ localContent = await fs_1.promises.readFile(fullPath, "utf8");
121
+ }
122
+ catch (e) {
123
+ localContent = null;
124
+ }
125
+ if (localContent === content) {
126
+ FileLogger_1.fileLogger.debug("Unchanged: " + fullPath);
127
+ return false;
128
+ }
129
+ if (localContent !== null) {
130
+ FileLogger_1.fileLogger.debug("Overwriting local content (differs from instance): " + fullPath);
131
+ }
132
+ else {
133
+ FileLogger_1.fileLogger.debug("Writing (missing locally): " + fullPath);
134
+ }
135
+ try {
136
+ await fs_1.promises.writeFile(fullPath, content);
137
+ return true;
138
+ }
139
+ catch (error) {
140
+ FileLogger_1.fileLogger.error("Failed to write " + fullPath + ":", error);
141
+ throw error;
142
+ }
143
+ };
144
+ exports.writeSNFileIfDifferent = writeSNFileIfDifferent;
106
145
  const createDirRecursively = async (path) => {
107
146
  await fs_1.promises.mkdir(path, { recursive: true });
108
147
  };
@@ -220,7 +220,8 @@ class MultiScopeWatcherManager {
220
220
  if (curSysId && curName) {
221
221
  var isDefault = curName === "Default" || curName.toLowerCase().indexOf("default") !== -1;
222
222
  if (isDefault) {
223
- Logger_1.logger.warn(`[${scopeName}] No update set configured and current update set is Default. ` +
223
+ Logger_1.logger.warn(`[${scopeName}] No update set configured for scope ${scopeName}. ` +
224
+ `Changes will go to Default. ` +
224
225
  `Use sinc createUpdateSet or activate a task in the dashboard.`);
225
226
  }
226
227
  else {
@@ -233,12 +234,14 @@ class MultiScopeWatcherManager {
233
234
  }
234
235
  else {
235
236
  Logger_1.logger.warn(`[${scopeName}] No update set configured for scope ${scopeName}. ` +
237
+ `Changes will go to Default. ` +
236
238
  `Use sinc createUpdateSet or activate a task in the dashboard.`);
237
239
  }
238
240
  }
239
241
  catch (queryErr) {
240
- Logger_1.logger.warn(`[${scopeName}] No update set configured and could not query current update set. ` +
241
- `Changes will use direct Table API. Use sinc createUpdateSet or activate a task in the dashboard.`);
242
+ Logger_1.logger.warn(`[${scopeName}] No update set configured for scope ${scopeName}. ` +
243
+ `Changes will go to Default (could not query current update set). ` +
244
+ `Use sinc createUpdateSet or activate a task in the dashboard.`);
242
245
  }
243
246
  return;
244
247
  }
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.findMissingFiles = exports.syncManifest = exports.processManifest = exports.normalizeManifestKeys = 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.processManifest = exports.normalizeManifestKeys = void 0;
40
40
  const path_1 = __importDefault(require("path"));
41
41
  const fs_1 = __importDefault(require("fs"));
42
42
  const progress_1 = __importDefault(require("progress"));
@@ -46,6 +46,7 @@ const constants_1 = require("./constants");
46
46
  const PluginManager_1 = __importDefault(require("./PluginManager"));
47
47
  const FileLogger_1 = require("./FileLogger");
48
48
  const snClient_1 = require("./snClient");
49
+ const benchmark_1 = require("./benchmark");
49
50
  const Logger_1 = require("./Logger");
50
51
  const genericUtils_1 = require("./genericUtils");
51
52
  const getUpdateSetConfig = () => {
@@ -106,9 +107,37 @@ const countRecordsInTables = (tables) => {
106
107
  return sum + Object.keys(tables[tableName].records).length;
107
108
  }, 0);
108
109
  };
109
- const processTablesInManifest = async (tables, forceWrite, sourcePath, onRecordProcessed) => {
110
+ const processTablesInManifest = async (tables, forceWrite, sourcePath, onRecordProcessed, scope) => {
110
111
  var basePath = sourcePath || ConfigManager.getSourcePath();
111
- const tableNames = Object.keys(tables);
112
+ var tableNames = Object.keys(tables);
113
+ // Defense-in-depth: filter out any table not in the resolved whitelist before
114
+ // writing to disk. Protects against upstream defects that let non-whitelisted
115
+ // tables through (e.g. server-side manifest fanout, stale manifest entries).
116
+ if (scope) {
117
+ try {
118
+ var resolved = ConfigManager.resolveConfigForScope(scope);
119
+ var allowed = resolved.tables;
120
+ if (allowed && allowed.length > 0) {
121
+ var skipped = [];
122
+ tableNames = tableNames.filter(function (t) {
123
+ if (allowed.indexOf(t) === -1) {
124
+ skipped.push(t);
125
+ return false;
126
+ }
127
+ return true;
128
+ });
129
+ if (skipped.length > 0) {
130
+ FileLogger_1.fileLogger.debug("processTablesInManifest: dropped " + skipped.length +
131
+ " non-whitelisted tables for scope '" + scope + "': " + skipped.join(", "));
132
+ }
133
+ }
134
+ }
135
+ catch (e) {
136
+ // Config resolution can fail for legacy single-scope manifests; fall
137
+ // through and process whatever the manifest contains.
138
+ FileLogger_1.fileLogger.debug("processTablesInManifest: could not resolve whitelist for scope '" + scope + "'");
139
+ }
140
+ }
112
141
  await (0, genericUtils_1.processBatched)(tableNames, constants_1.CONCURRENCY_TABLES, function (tableName) {
113
142
  return processRecsInManTable(path_1.default.join(basePath, tableName), tables[tableName], forceWrite, onRecordProcessed);
114
143
  });
@@ -149,7 +178,7 @@ const processManifest = async (manifest, forceWrite = false, sourcePath) => {
149
178
  scope: manifest.scope || "default",
150
179
  total: recordCount,
151
180
  });
152
- await processTablesInManifest(manifest.tables, forceWrite, sourcePath, progress.tick);
181
+ await processTablesInManifest(manifest.tables, forceWrite, sourcePath, progress.tick, manifest.scope);
153
182
  if (manifest.scope) {
154
183
  await fUtils.writeScopeManifest(manifest.scope, manifest);
155
184
  }
@@ -158,23 +187,74 @@ const processManifest = async (manifest, forceWrite = false, sourcePath) => {
158
187
  }
159
188
  };
160
189
  exports.processManifest = processManifest;
161
- const syncManifest = async (scope) => {
190
+ const syncManifest = async (scope, options = {}) => {
191
+ // Top-level entry owns the collector lifecycle. Recursive calls (all-scopes
192
+ // → per-scope) inherit the collector via options._benchmarkCollector.
193
+ var isBenchmarkOwner = false;
194
+ var collector = options._benchmarkCollector;
195
+ if (options.benchmark && !collector) {
196
+ collector = new benchmark_1.BenchmarkCollector();
197
+ (0, snClient_1.setBenchmarkSink)(collector);
198
+ isBenchmarkOwner = true;
199
+ }
162
200
  try {
163
201
  const curManifest = await ConfigManager.getManifest();
164
202
  if (!curManifest)
165
203
  throw new Error("No manifest file loaded!");
204
+ const config = ConfigManager.getConfig();
205
+ const declaredScopes = (config.scopes && Object.keys(config.scopes)) || [];
166
206
  // If a specific scope is provided, sync only that scope
167
207
  if (scope) {
208
+ // Scope whitelist gate: refuse to refresh scopes not declared in sinc.config.js.
209
+ // Without this, stale entries in sinc.manifest.json leak undeclared scopes into
210
+ // the refresh loop (see RFC-0004 / sys_alias debris incident 2026-04-14).
211
+ if (declaredScopes.length > 0 && declaredScopes.indexOf(scope) === -1) {
212
+ Logger_1.logger.warn("Skipping scope '" + scope + "' — not declared in sinc.config.js `scopes`. " +
213
+ "Add it to config.scopes to sync, or remove its manifest file.");
214
+ FileLogger_1.fileLogger.debug("syncManifest: skipped undeclared scope '" + scope + "'");
215
+ return;
216
+ }
168
217
  Logger_1.logger.info("Refreshing scope: " + scope + "...");
169
218
  const client = (0, snClient_1.defaultClient)();
170
- const config = ConfigManager.getConfig();
171
- // Resolve scope-specific source directory
219
+ // Resolve scope-specific source directory + table whitelist
172
220
  var scopeSourcePath = ConfigManager.getSourcePathForScope(scope);
221
+ var resolvedConfig = ConfigManager.resolveConfigForScope(scope);
222
+ var allowedTables = resolvedConfig.tables;
173
223
  const newManifest = (0, exports.normalizeManifestKeys)(await (0, snClient_1.unwrapSNResponse)(client.getManifest(scope, config)));
224
+ // Table whitelist gate: drop any table the server returned that is not in
225
+ // the resolved _tables whitelist for this scope. Mirrors the filter in
226
+ // commands.ts downloadCommand() and allScopesCommands.ts processScope().
227
+ if (allowedTables && allowedTables.length > 0) {
228
+ var manifestTableNames = Object.keys(newManifest.tables || {});
229
+ var filteredTables = {};
230
+ var skippedCount = 0;
231
+ for (var t = 0; t < manifestTableNames.length; t++) {
232
+ var tName = manifestTableNames[t];
233
+ if (allowedTables.indexOf(tName) !== -1) {
234
+ filteredTables[tName] = newManifest.tables[tName];
235
+ }
236
+ else {
237
+ skippedCount++;
238
+ }
239
+ }
240
+ if (skippedCount > 0) {
241
+ FileLogger_1.fileLogger.debug("syncManifest: filtered " + skippedCount + " tables not in _tables whitelist for " +
242
+ scope + " (kept " + Object.keys(filteredTables).length + " of " + manifestTableNames.length + ")");
243
+ }
244
+ newManifest.tables = filteredTables;
245
+ }
246
+ else {
247
+ Logger_1.logger.warn("No _tables whitelist defined — writing ALL tables for " + scope);
248
+ }
174
249
  const refreshTableCount = Object.keys(newManifest.tables).length;
175
250
  FileLogger_1.fileLogger.debug("Refreshed manifest for " + scope + ": " + refreshTableCount + " tables");
176
251
  await fUtils.writeScopeManifest(scope, newManifest);
177
- await (0, exports.processMissingFiles)(newManifest, scopeSourcePath);
252
+ if (collector)
253
+ collector.startScope(scope);
254
+ await (0, exports.refreshAllFiles)(newManifest, scopeSourcePath, {
255
+ force: options.force,
256
+ benchmarkCollector: collector,
257
+ });
178
258
  // Update the in-memory manifest for this scope
179
259
  if (ConfigManager.isMultiScopeManifest(curManifest)) {
180
260
  curManifest[scope] = newManifest;
@@ -182,16 +262,27 @@ const syncManifest = async (scope) => {
182
262
  }
183
263
  }
184
264
  else {
185
- // Sync all scopes if manifest has multiple scopes
186
- if (ConfigManager.isMultiScopeManifest(curManifest)) {
187
- // Multiple scopes detected
265
+ // Sync all scopes. Prefer the declared-scopes list (config.scopes) over
266
+ // the persisted manifest keys — the manifest may contain stale undeclared
267
+ // scopes that leaked in before the whitelist gate existed.
268
+ var childOptions = {
269
+ force: options.force,
270
+ _benchmarkCollector: collector,
271
+ };
272
+ if (declaredScopes.length > 0) {
273
+ for (var d = 0; d < declaredScopes.length; d++) {
274
+ await (0, exports.syncManifest)(declaredScopes[d], childOptions);
275
+ }
276
+ }
277
+ else if (ConfigManager.isMultiScopeManifest(curManifest)) {
278
+ // No declared scopes — fall back to the persisted manifest's scopes.
188
279
  for (const scopeName of Object.keys(curManifest)) {
189
- await (0, exports.syncManifest)(scopeName);
280
+ await (0, exports.syncManifest)(scopeName, childOptions);
190
281
  }
191
282
  }
192
283
  else if (curManifest.scope) {
193
284
  // Single scope manifest
194
- await (0, exports.syncManifest)(curManifest.scope);
285
+ await (0, exports.syncManifest)(curManifest.scope, childOptions);
195
286
  }
196
287
  }
197
288
  }
@@ -203,6 +294,12 @@ const syncManifest = async (scope) => {
203
294
  message = String(e);
204
295
  Logger_1.logger.error("Refresh failed: " + message);
205
296
  }
297
+ finally {
298
+ if (isBenchmarkOwner && collector) {
299
+ (0, snClient_1.setBenchmarkSink)(null);
300
+ Logger_1.logger.info(collector.formatSummary());
301
+ }
302
+ }
206
303
  };
207
304
  exports.syncManifest = syncManifest;
208
305
  const markFileMissing = (missingObj) => (table) => (recordId) => (file) => {
@@ -274,6 +371,158 @@ const findMissingFiles = async (manifest, sourcePath) => {
274
371
  return missing;
275
372
  };
276
373
  exports.findMissingFiles = findMissingFiles;
374
+ // Chunk bulkDownload by table to stay under ServiceNow's 10 MB REST payload cap.
375
+ // A single unchunked call 500s on large scopes (e.g. x_cadso_automate at ~29 MB).
376
+ // Must mirror the chunk size used by allScopesCommands.ts (watch path) so behaviour
377
+ // is consistent across `refresh` and `watch`.
378
+ const BULK_DOWNLOAD_TABLE_CHUNK_SIZE = 5;
379
+ /**
380
+ * Builds a MissingFileTableMap containing EVERY file in the manifest — ignores
381
+ * local disk state. Used by `sinc refresh` to pull instance-side edits down.
382
+ */
383
+ const buildAllFilesMap = (manifest) => {
384
+ const result = {};
385
+ const { tables } = manifest;
386
+ const tableNames = Object.keys(tables);
387
+ for (var t = 0; t < tableNames.length; t++) {
388
+ var tableName = tableNames[t];
389
+ var records = tables[tableName].records;
390
+ var recNames = Object.keys(records);
391
+ if (recNames.length === 0)
392
+ continue;
393
+ var recMap = {};
394
+ for (var r = 0; r < recNames.length; r++) {
395
+ var rec = records[recNames[r]];
396
+ if (!rec.files || rec.files.length === 0)
397
+ continue;
398
+ // Strip any content that may be lingering on manifest file entries; the
399
+ // bulkDownload endpoint only needs name + type to resolve each field.
400
+ recMap[rec.sys_id] = rec.files.map(function (f) {
401
+ return { name: f.name, type: f.type };
402
+ });
403
+ }
404
+ if (Object.keys(recMap).length > 0)
405
+ result[tableName] = recMap;
406
+ }
407
+ return result;
408
+ };
409
+ /**
410
+ * Refreshes local files against the ServiceNow instance for every file in the
411
+ * given manifest. Unlike `processMissingFiles` (which only writes files absent
412
+ * from disk), this walks ALL manifest files, fetches their current content from
413
+ * the instance, and writes when content differs.
414
+ *
415
+ * @param options.force — when true, always overwrite local files even if their
416
+ * content matches the instance. Use for deliberate "reset local to instance".
417
+ */
418
+ const refreshAllFiles = async (newManifest, sourcePath, options = {}) => {
419
+ try {
420
+ const allFiles = buildAllFilesMap(newManifest);
421
+ const tableNames = Object.keys(allFiles);
422
+ if (tableNames.length === 0)
423
+ return;
424
+ FileLogger_1.fileLogger.debug("Refreshing file content for " + tableNames.length + " tables (force=" + !!options.force + ")");
425
+ const { tableOptions = {} } = ConfigManager.getConfig();
426
+ const client = (0, snClient_1.defaultClient)();
427
+ const totalChunks = Math.ceil(tableNames.length / BULK_DOWNLOAD_TABLE_CHUNK_SIZE);
428
+ const filesToProcess = {};
429
+ for (var i = 0; i < tableNames.length; i += BULK_DOWNLOAD_TABLE_CHUNK_SIZE) {
430
+ const chunkTableNames = tableNames.slice(i, i + BULK_DOWNLOAD_TABLE_CHUNK_SIZE);
431
+ const chunkMissing = {};
432
+ for (var j = 0; j < chunkTableNames.length; j++) {
433
+ chunkMissing[chunkTableNames[j]] = allFiles[chunkTableNames[j]];
434
+ }
435
+ const batchNum = Math.floor(i / BULK_DOWNLOAD_TABLE_CHUNK_SIZE) + 1;
436
+ FileLogger_1.fileLogger.debug("Refresh download batch " + batchNum + "/" + totalChunks +
437
+ " (" + chunkTableNames.length + " tables): " + chunkTableNames.join(", "));
438
+ const chunkResult = await (0, snClient_1.unwrapSNResponse)(client.getMissingFiles(chunkMissing, tableOptions));
439
+ for (var tableName in chunkResult) {
440
+ filesToProcess[tableName] = chunkResult[tableName];
441
+ }
442
+ }
443
+ var basePath = sourcePath || ConfigManager.getSourcePath();
444
+ var recordCount = countRecordsInTables(filesToProcess);
445
+ var progress = createScopeProgress(Logger_1.logger.getLogLevel(), {
446
+ scope: newManifest.scope || "default",
447
+ total: recordCount,
448
+ });
449
+ var writtenCount = 0;
450
+ var unchangedCount = 0;
451
+ const forceWrite = !!options.force;
452
+ const forceWriter = fUtils.writeSNFileCurry(false);
453
+ const processedTableNames = Object.keys(filesToProcess);
454
+ await (0, genericUtils_1.processBatched)(processedTableNames, constants_1.CONCURRENCY_TABLES, async function (tableName) {
455
+ var tablePath = path_1.default.join(basePath, tableName);
456
+ var recs = filesToProcess[tableName].records;
457
+ var recKeys = Object.keys(recs);
458
+ await Promise.all(recKeys.map(function (k) {
459
+ return fUtils.createDirRecursively(path_1.default.join(tablePath, recs[k].name));
460
+ }));
461
+ await (0, genericUtils_1.processBatched)(recKeys, constants_1.CONCURRENCY_RECORDS, async function (recKey) {
462
+ var rec = recs[recKey];
463
+ var recPath = path_1.default.join(tablePath, rec.name);
464
+ var results = await (0, genericUtils_1.allSettledBatched)(rec.files, constants_1.CONCURRENCY_FILES, async function (file) {
465
+ if (forceWrite) {
466
+ await forceWriter(file, recPath);
467
+ return true;
468
+ }
469
+ return fUtils.writeSNFileIfDifferent(file, recPath);
470
+ });
471
+ var anyChanged = false;
472
+ for (var f = 0; f < results.length; f++) {
473
+ var res = results[f];
474
+ if (res.status === "rejected") {
475
+ FileLogger_1.fileLogger.error("File write failed: " + res.reason);
476
+ continue;
477
+ }
478
+ if (res.value) {
479
+ anyChanged = true;
480
+ writtenCount++;
481
+ }
482
+ else {
483
+ unchangedCount++;
484
+ }
485
+ }
486
+ // Only touch metaData when at least one file in the record actually
487
+ // changed. Avoids rewriting _lastUpdatedOn for records that were
488
+ // already in sync with the instance.
489
+ if (anyChanged || forceWrite) {
490
+ const metadataFile = {
491
+ name: "metaData",
492
+ type: "json",
493
+ content: JSON.stringify({ _lastUpdatedOn: new Date().toISOString() }, null, 2),
494
+ };
495
+ await forceWriter(metadataFile, recPath);
496
+ }
497
+ // Strip content from manifest entries to keep memory bounded.
498
+ rec.files = rec.files.map(function (file) {
499
+ var copy = Object.assign({}, file);
500
+ delete copy.content;
501
+ return copy;
502
+ });
503
+ progress.tick();
504
+ });
505
+ });
506
+ FileLogger_1.fileLogger.debug("Refresh complete: " + writtenCount + " written, " + unchangedCount + " unchanged");
507
+ if (writtenCount > 0) {
508
+ Logger_1.logger.info("Refreshed " + writtenCount + " file(s) from instance" +
509
+ (unchangedCount > 0 ? " (" + unchangedCount + " already in sync)" : ""));
510
+ }
511
+ else {
512
+ Logger_1.logger.debug("No file changes detected from instance (" + unchangedCount + " checked)");
513
+ }
514
+ if (options.benchmarkCollector) {
515
+ options.benchmarkCollector.endScope(writtenCount, unchangedCount);
516
+ }
517
+ }
518
+ catch (e) {
519
+ if (options.benchmarkCollector) {
520
+ options.benchmarkCollector.endScope(0, 0);
521
+ }
522
+ throw e;
523
+ }
524
+ };
525
+ exports.refreshAllFiles = refreshAllFiles;
277
526
  const processMissingFiles = async (newManifest, sourcePath) => {
278
527
  try {
279
528
  const missing = await (0, exports.findMissingFiles)(newManifest, sourcePath);
@@ -283,13 +532,32 @@ const processMissingFiles = async (newManifest, sourcePath) => {
283
532
  FileLogger_1.fileLogger.debug("Downloading missing files from " + missingTableCount + " tables");
284
533
  const { tableOptions = {} } = ConfigManager.getConfig();
285
534
  const client = (0, snClient_1.defaultClient)();
286
- const filesToProcess = await (0, snClient_1.unwrapSNResponse)(client.getMissingFiles(missing, tableOptions));
535
+ // Chunk the bulkDownload request: ServiceNow rejects REST payloads > 10 MB,
536
+ // so send table batches and merge the results before processing.
537
+ const tableNames = Object.keys(missing);
538
+ const totalChunks = Math.ceil(tableNames.length / BULK_DOWNLOAD_TABLE_CHUNK_SIZE);
539
+ const filesToProcess = {};
540
+ for (var i = 0; i < tableNames.length; i += BULK_DOWNLOAD_TABLE_CHUNK_SIZE) {
541
+ const chunkTableNames = tableNames.slice(i, i + BULK_DOWNLOAD_TABLE_CHUNK_SIZE);
542
+ const chunkMissing = {};
543
+ for (var j = 0; j < chunkTableNames.length; j++) {
544
+ chunkMissing[chunkTableNames[j]] = missing[chunkTableNames[j]];
545
+ }
546
+ const batchNum = Math.floor(i / BULK_DOWNLOAD_TABLE_CHUNK_SIZE) + 1;
547
+ FileLogger_1.fileLogger.debug("Bulk download batch " + batchNum + "/" + totalChunks +
548
+ " (" + chunkTableNames.length + " tables): " + chunkTableNames.join(", "));
549
+ const chunkResult = await (0, snClient_1.unwrapSNResponse)(client.getMissingFiles(chunkMissing, tableOptions));
550
+ // Chunks are partitioned by table key, so merging is a simple assign.
551
+ for (var tableName in chunkResult) {
552
+ filesToProcess[tableName] = chunkResult[tableName];
553
+ }
554
+ }
287
555
  var recordCount = countRecordsInTables(filesToProcess);
288
556
  var progress = createScopeProgress(Logger_1.logger.getLogLevel(), {
289
557
  scope: newManifest.scope || "default",
290
558
  total: recordCount,
291
559
  });
292
- await processTablesInManifest(filesToProcess, false, sourcePath, progress.tick);
560
+ await processTablesInManifest(filesToProcess, false, sourcePath, progress.tick, newManifest.scope);
293
561
  }
294
562
  catch (e) {
295
563
  throw e;
@@ -0,0 +1,104 @@
1
+ "use strict";
2
+ /**
3
+ * Opt-in benchmark collector for `sinc refresh`. Wired into snClient via
4
+ * setBenchmarkSink — when null (the default), there is zero overhead.
5
+ *
6
+ * PR #36 changed refresh from "download files absent from disk" to "bulk-download
7
+ * every manifest file and compare before writing". That shifted the request
8
+ * profile silently. This collector surfaces the numbers so regressions are
9
+ * visible.
10
+ */
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.BenchmarkCollector = void 0;
13
+ class BenchmarkCollector {
14
+ httpSamples = [];
15
+ scopeSamples = [];
16
+ currentScope = null;
17
+ recordHttp(sample) {
18
+ this.httpSamples.push(sample);
19
+ }
20
+ startScope(scopeName) {
21
+ this.currentScope = {
22
+ name: scopeName,
23
+ startedAt: Date.now(),
24
+ httpStart: this.httpSamples.length,
25
+ bytesStart: this.totalBytes(),
26
+ };
27
+ }
28
+ endScope(filesWritten, filesUnchanged) {
29
+ if (!this.currentScope)
30
+ return;
31
+ var scope = this.currentScope;
32
+ this.scopeSamples.push({
33
+ scopeName: scope.name,
34
+ wallTimeMs: Date.now() - scope.startedAt,
35
+ httpRequests: this.httpSamples.length - scope.httpStart,
36
+ filesWritten: filesWritten,
37
+ filesUnchanged: filesUnchanged,
38
+ totalResponseBytes: this.totalBytes() - scope.bytesStart,
39
+ });
40
+ this.currentScope = null;
41
+ }
42
+ getHttpSamples() {
43
+ return this.httpSamples.slice();
44
+ }
45
+ getScopeSamples() {
46
+ return this.scopeSamples.slice();
47
+ }
48
+ totalBytes() {
49
+ var total = 0;
50
+ for (var i = 0; i < this.httpSamples.length; i++) {
51
+ total += this.httpSamples[i].responseBytes;
52
+ }
53
+ return total;
54
+ }
55
+ formatSummary() {
56
+ var lines = [];
57
+ lines.push("");
58
+ lines.push("=".repeat(72));
59
+ lines.push("Refresh Benchmark");
60
+ lines.push("=".repeat(72));
61
+ if (this.httpSamples.length === 0) {
62
+ lines.push("(no samples recorded)");
63
+ return lines.join("\n");
64
+ }
65
+ var latencies = this.httpSamples
66
+ .map(function (s) { return s.durationMs; })
67
+ .sort(function (a, b) { return a - b; });
68
+ var p50 = percentile(latencies, 0.5);
69
+ var p95 = percentile(latencies, 0.95);
70
+ var max = latencies[latencies.length - 1];
71
+ var totalBytes = this.totalBytes();
72
+ lines.push("Overall: " + this.httpSamples.length + " HTTP requests, " +
73
+ formatBytes(totalBytes) + " received");
74
+ lines.push("Latency: p50 " + p50 + "ms | p95 " + p95 + "ms | max " + max + "ms");
75
+ if (this.scopeSamples.length > 0) {
76
+ lines.push("");
77
+ lines.push("Per-scope:");
78
+ for (var i = 0; i < this.scopeSamples.length; i++) {
79
+ var s = this.scopeSamples[i];
80
+ lines.push(" " + s.scopeName + ": " +
81
+ s.wallTimeMs + "ms wall, " +
82
+ s.httpRequests + " req, " +
83
+ formatBytes(s.totalResponseBytes) + ", " +
84
+ s.filesWritten + " written / " + s.filesUnchanged + " unchanged");
85
+ }
86
+ }
87
+ lines.push("=".repeat(72));
88
+ return lines.join("\n");
89
+ }
90
+ }
91
+ exports.BenchmarkCollector = BenchmarkCollector;
92
+ function percentile(sortedAsc, p) {
93
+ if (sortedAsc.length === 0)
94
+ return 0;
95
+ var idx = Math.min(sortedAsc.length - 1, Math.floor(sortedAsc.length * p));
96
+ return sortedAsc[idx];
97
+ }
98
+ function formatBytes(n) {
99
+ if (n < 1024)
100
+ return n + "B";
101
+ if (n < 1024 * 1024)
102
+ return (n / 1024).toFixed(1) + "KB";
103
+ return (n / (1024 * 1024)).toFixed(2) + "MB";
104
+ }
package/dist/commander.js CHANGED
@@ -50,7 +50,31 @@ async function initCommands() {
50
50
  }, async (args) => {
51
51
  await (0, allScopesCommands_1.watchAllScopesCommand)(args);
52
52
  })
53
- .command(["refresh", "r"], "Refresh Manifest and download new files since last refresh", sharedOptions, commands_1.refreshCommand)
53
+ .command(["refresh", "r"], "Pull latest manifest and file contents from the ServiceNow instance", (cmdArgs) => {
54
+ cmdArgs.options({
55
+ ...sharedOptions,
56
+ force: {
57
+ alias: "f",
58
+ type: "boolean",
59
+ default: false,
60
+ describe: "Overwrite local files even when content matches the instance",
61
+ },
62
+ scope: {
63
+ alias: "s",
64
+ type: "string",
65
+ describe: "Refresh a single scope (default: all declared scopes)",
66
+ },
67
+ benchmark: {
68
+ alias: "b",
69
+ type: "boolean",
70
+ default: false,
71
+ describe: "Log per-scope and aggregate HTTP latency, bytes, and file counts",
72
+ },
73
+ });
74
+ return cmdArgs;
75
+ }, async (args) => {
76
+ await (0, commands_1.refreshCommand)(args);
77
+ })
54
78
  .command(["push [target]"], "[DESTRUCTIVE] Push all files from current local files to ServiceNow instance.", (cmdArgs) => {
55
79
  cmdArgs.options({
56
80
  ...sharedOptions,
package/dist/commands.js CHANGED
@@ -65,8 +65,11 @@ async function refreshCommand(args, log = true) {
65
65
  try {
66
66
  if (!log)
67
67
  setLogLevel({ logLevel: "warn" });
68
- FileLogger_1.fileLogger.debug("Syncing manifest from instance");
69
- await AppUtils.syncManifest();
68
+ FileLogger_1.fileLogger.debug("Syncing manifest from instance (force=" + !!args.force + ", benchmark=" + !!args.benchmark + ")");
69
+ await AppUtils.syncManifest(args.scope, {
70
+ force: !!args.force,
71
+ benchmark: !!args.benchmark,
72
+ });
70
73
  Logger_1.logger.success("Refresh complete!");
71
74
  setLogLevel(args);
72
75
  }