@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 +40 -1
- package/dist/MultiScopeWatcher.js +6 -3
- package/dist/appUtils.js +283 -15
- package/dist/benchmark.js +104 -0
- package/dist/commander.js +25 -1
- package/dist/commands.js +5 -2
- package/dist/snClient.js +105 -8
- package/dist/tests/benchmarkRefresh.test.js +265 -0
- package/dist/tests/globalDebounce.test.js +3 -2
- package/dist/tests/multi-scope-watcher.test.js +10 -20
- package/dist/tests/scopeCaching.test.js +14 -23
- package/dist/tests/syncManifestRefresh.test.js +287 -0
- package/dist/tests/syncManifestWhitelist.test.js +192 -0
- package/dist/tests/unwrapSNResponseError.test.js +145 -0
- package/package.json +1 -1
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
|
|
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
|
|
241
|
-
`Changes will
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
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"], "
|
|
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
|
}
|