v8r 5.0.0 → 5.1.0

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/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## 📦 [5.1.0](https://www.npmjs.com/package/v8r/v/5.1.0) - 2025-07-20
4
+
5
+ * v8r now pre-warms the cache and fetches schemas in parallel.
6
+ This will improve decrease total run time for any run that involves fetching
7
+ more than one remote schema, or involves a schema with remote `$ref`s.
8
+ * Improve handling of empty yaml files.
9
+
3
10
  ## 📦 [5.0.0](https://www.npmjs.com/package/v8r/v/5.0.0) - 2025-05-10
4
11
 
5
12
  Following on from the deprecations in version 4.4.0,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "v8r",
3
- "version": "5.0.0",
3
+ "version": "5.1.0",
4
4
  "description": "A command-line JSON, YAML and TOML validator that's on your wavelength",
5
5
  "scripts": {
6
6
  "test": "V8R_CACHE_NAME=v8r-test c8 --reporter=text mocha \"src/**/*.spec.js\"",
@@ -42,14 +42,16 @@
42
42
  "js-yaml": "^4.0.0",
43
43
  "json5": "^2.2.0",
44
44
  "minimatch": "^10.0.0",
45
+ "p-limit": "^6.2.0",
46
+ "p-mutex": "^1.0.0",
45
47
  "smol-toml": "^1.0.1",
46
- "yargs": "^17.0.1"
48
+ "yargs": "^18.0.0"
47
49
  },
48
50
  "devDependencies": {
49
51
  "c8": "^10.1.2",
50
52
  "eslint": "^9.9.0",
51
53
  "eslint-config-prettier": "^10.1.2",
52
- "eslint-plugin-jsdoc": "^50.2.2",
54
+ "eslint-plugin-jsdoc": "^51.4.1",
53
55
  "eslint-plugin-mocha": "^11.0.0",
54
56
  "eslint-plugin-prettier": "^5.0.0",
55
57
  "mocha": "^11.0.1",
package/src/bootstrap.js CHANGED
@@ -53,6 +53,8 @@ function mergeConfigs(args, config) {
53
53
  if (config.filepath) {
54
54
  mergedConfig.configFileRelativePath = getRelativeFilePath(config);
55
55
  }
56
+ // hard-coded - this can't be set via CLI or config file
57
+ mergedConfig.cachePrewarm = true;
56
58
  return mergedConfig;
57
59
  }
58
60
 
@@ -0,0 +1,87 @@
1
+ import isUrl from "is-url";
2
+ import pLimit from "p-limit";
3
+ import { getCatalogs, getMatchForFilename } from "./catalogs.js";
4
+
5
+ const limit = pLimit(10);
6
+
7
+ async function fetch(location, cache) {
8
+ return await cache.fetch(location, false);
9
+ }
10
+
11
+ function fetchWithLimit(url, cache) {
12
+ return limit(() => fetch(url, cache));
13
+ }
14
+
15
+ function normalizeUrl(ref) {
16
+ try {
17
+ const url = new URL(ref);
18
+ url.hash = "";
19
+ return url.toString();
20
+ } catch {
21
+ return null;
22
+ }
23
+ }
24
+
25
+ function getRemoteRefs(node) {
26
+ let refs = [];
27
+ if (Array.isArray(node)) {
28
+ for (const v of node) {
29
+ if (typeof v === "object" && v !== null) {
30
+ refs = refs.concat(getRemoteRefs(v));
31
+ }
32
+ }
33
+ } else if (typeof node === "object" && node !== null) {
34
+ for (const [k, v] of Object.entries(node)) {
35
+ if (k === "$ref" && typeof v === "string") {
36
+ const resolved = normalizeUrl(v);
37
+ if (resolved && isUrl(resolved)) {
38
+ refs.push(resolved);
39
+ }
40
+ }
41
+ if (typeof v === "object" && v !== null) {
42
+ refs = refs.concat(getRemoteRefs(v));
43
+ }
44
+ }
45
+ }
46
+ return Array.from(new Set(refs));
47
+ }
48
+
49
+ async function fetchAndRecurse(url, cache) {
50
+ const schema = await fetchWithLimit(url, cache);
51
+ const refs = getRemoteRefs(schema).filter(
52
+ (ref) => cache.get(ref) === undefined,
53
+ );
54
+ await Promise.all(refs.map((ref) => fetchAndRecurse(ref, cache)));
55
+ }
56
+
57
+ async function prewarmSchemaCache(filenames, config, cache) {
58
+ const catalogs = getCatalogs(config);
59
+ const schemaLocations = new Set();
60
+
61
+ for (const filename of filenames) {
62
+ let catalogMatch;
63
+ try {
64
+ catalogMatch = config.schema
65
+ ? {}
66
+ : await getMatchForFilename(catalogs, filename, "debug", cache);
67
+ } catch {
68
+ catalogMatch = {};
69
+ }
70
+ const schemaLocation = config.schema || catalogMatch.location;
71
+
72
+ if (schemaLocation) {
73
+ schemaLocations.add(schemaLocation);
74
+ }
75
+ cache.resetCounters();
76
+ }
77
+
78
+ await Promise.all(
79
+ Array.from(schemaLocations)
80
+ .filter((schemaLocation) => isUrl(schemaLocation))
81
+ .map((url) => fetchAndRecurse(url, cache)),
82
+ );
83
+ cache.persist();
84
+ cache.resetCounters();
85
+ }
86
+
87
+ export { getRemoteRefs, prewarmSchemaCache, normalizeUrl };
package/src/cache.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import got from "got";
2
+ import Mutex from "p-mutex";
2
3
  import logger from "./logger.js";
3
4
  import { parseSchema } from "./parser.js";
4
5
 
@@ -7,39 +8,53 @@ class Cache {
7
8
  this.cache = flatCache;
8
9
  this.ttl = this.cache._cache.ttl || 0;
9
10
  this.callCounter = {};
11
+ this.locks = {};
10
12
  this.callLimit = 10;
11
13
  if (this.ttl === 0) {
12
14
  this.cache.clear();
13
15
  }
14
16
  }
15
17
 
16
- limitDepth(url) {
18
+ getMutex(url) {
19
+ if (!(url in this.locks)) {
20
+ this.locks[url] = new Mutex();
21
+ }
22
+ return this.locks[url];
23
+ }
24
+
25
+ async limitDepth(url) {
17
26
  /*
18
27
  It is possible to create cyclic dependencies with external references
19
- in JSON schema. Ajv doesn't detect this when resolving external references,
28
+ in JSON schema.
29
+ We try to mitigate this issue during cache pre-warming.
30
+ Ajv doesn't detect this when resolving external references,
20
31
  so we keep a count of how many times we've called the same URL.
21
32
  If we are calling the same URL over and over we've probably hit a circular
22
33
  external reference and we need to break the loop.
23
34
  */
24
- if (url in this.callCounter) {
25
- this.callCounter[url]++;
26
- } else {
27
- this.callCounter[url] = 1;
28
- }
29
- if (this.callCounter[url] > this.callLimit) {
30
- throw new Error(
31
- `Called ${url} >${this.callLimit} times. Possible circular reference.`,
32
- );
33
- }
35
+ const mutex = this.getMutex(url);
36
+
37
+ await mutex.withLock(async () => {
38
+ if (url in this.callCounter) {
39
+ this.callCounter[url]++;
40
+ } else {
41
+ this.callCounter[url] = 1;
42
+ }
43
+ if (this.callCounter[url] > this.callLimit) {
44
+ throw new Error(
45
+ `Called ${url} >${this.callLimit} times. Possible circular reference.`,
46
+ );
47
+ }
48
+ });
34
49
  }
35
50
 
36
51
  resetCounters() {
37
52
  this.callCounter = {};
38
53
  }
39
54
 
40
- async fetch(url) {
41
- this.limitDepth(url);
42
- const cachedResponse = this.cache.getKey(url);
55
+ async fetch(url, persist = true) {
56
+ await this.limitDepth(url);
57
+ const cachedResponse = this.cache.get(url);
43
58
  if (cachedResponse !== undefined) {
44
59
  logger.debug(`Cache hit: using cached response from ${url}`);
45
60
  return cachedResponse.body;
@@ -50,8 +65,10 @@ class Cache {
50
65
  const resp = await got(url);
51
66
  const parsedBody = parseSchema(resp.body, url);
52
67
  if (this.ttl > 0) {
53
- this.cache.setKey(url, { body: parsedBody });
54
- this.cache.save(true);
68
+ this.cache.set(url, { body: parsedBody });
69
+ if (persist) {
70
+ this.cache.save(true);
71
+ }
55
72
  }
56
73
  return parsedBody;
57
74
  } catch (error) {
@@ -61,6 +78,14 @@ class Cache {
61
78
  throw new Error(`Failed fetching ${url}`);
62
79
  }
63
80
  }
81
+
82
+ persist() {
83
+ this.cache.save(true);
84
+ }
85
+
86
+ get(key) {
87
+ return this.cache.get(key);
88
+ }
64
89
  }
65
90
 
66
91
  export { Cache };
package/src/catalogs.js CHANGED
@@ -74,7 +74,7 @@ function getMultipleMatchesLogMessage(matches) {
74
74
  .join("\n");
75
75
  }
76
76
 
77
- async function getMatchForFilename(catalogs, filename, cache) {
77
+ async function getMatchForFilename(catalogs, filename, logLevel, cache) {
78
78
  for (const [i, rec] of catalogs.entries()) {
79
79
  const catalogLocation = rec.location;
80
80
  const catalog =
@@ -107,7 +107,7 @@ async function getMatchForFilename(catalogs, filename, cache) {
107
107
  (matches.length === 1 && matches[0].versions == null) ||
108
108
  (matches.length === 1 && Object.keys(matches[0].versions).length === 1)
109
109
  ) {
110
- logger.info(`Found schema in ${catalogLocation} ...`);
110
+ logger[logLevel](`Found schema in ${catalogLocation} ...`);
111
111
  return coerceMatch(matches[0]); // Exactly one match found. We're done.
112
112
  }
113
113
 
@@ -122,7 +122,7 @@ async function getMatchForFilename(catalogs, filename, cache) {
122
122
  ) {
123
123
  // We found >1 matches in the same catalog. This is always a hard error.
124
124
  const matchesLog = getMultipleMatchesLogMessage(matches);
125
- logger.info(
125
+ logger[logLevel](
126
126
  `Found multiple possible matches for ${filename}. Possible matches:\n\n${matchesLog}`,
127
127
  );
128
128
  throw new Error(
package/src/cli.js CHANGED
@@ -12,6 +12,7 @@ import { getFromUrlOrFile } from "./io.js";
12
12
  import logger from "./logger.js";
13
13
  import { getDocumentLocation } from "./output-formatters.js";
14
14
  import { parseFile } from "./parser.js";
15
+ import { prewarmSchemaCache } from "./cache-prewarm.js";
15
16
 
16
17
  const EXIT = {
17
18
  VALID: 0,
@@ -92,7 +93,7 @@ async function validateFile(filename, config, plugins, cache) {
92
93
  const catalogs = getCatalogs(config);
93
94
  const catalogMatch = config.schema
94
95
  ? {}
95
- : await getMatchForFilename(catalogs, filename, cache);
96
+ : await getMatchForFilename(catalogs, filename, "info", cache);
96
97
  schemaLocation = config.schema || catalogMatch.location;
97
98
  schema = await getFromUrlOrFile(schemaLocation, cache);
98
99
  logger.info(
@@ -182,6 +183,12 @@ function Validator() {
182
183
  const ttl = secondsToMilliseconds(config.cacheTtl || 0);
183
184
  const cache = new Cache(getFlatCache(ttl));
184
185
 
186
+ if (config.cachePrewarm && ttl > 5000) {
187
+ logger.info("Pre-warming the cache");
188
+ await prewarmSchemaCache(filenames, config, cache);
189
+ logger.debug("Cache pre-warming complete");
190
+ }
191
+
185
192
  let results = [];
186
193
  for (const filename of filenames) {
187
194
  const fileResults = await validateFile(filename, config, plugins, cache);
package/src/parser.js CHANGED
@@ -10,6 +10,9 @@ function parseFile(plugins, contents, filename, parser) {
10
10
  const maybeDocuments = Array.isArray(parsedFile)
11
11
  ? parsedFile
12
12
  : [parsedFile];
13
+ if (maybeDocuments.length === 0) {
14
+ throw new Error(`No documents to validate found in ${filename}`);
15
+ }
13
16
  for (const doc of maybeDocuments) {
14
17
  if (!(doc instanceof Document)) {
15
18
  throw new Error(
@@ -1,4 +1,3 @@
1
- import { clearCacheById } from "flat-cache";
2
1
  import logger from "./logger.js";
3
2
 
4
3
  const origWriteOut = logger.writeOut;
@@ -7,7 +6,6 @@ const testCacheName = process.env.V8R_CACHE_NAME;
7
6
  const env = process.env;
8
7
 
9
8
  function setUp() {
10
- clearCacheById(testCacheName);
11
9
  logger.resetStdout();
12
10
  logger.resetStderr();
13
11
  logger.writeOut = function () {};
@@ -16,7 +14,6 @@ function setUp() {
16
14
  }
17
15
 
18
16
  function tearDown() {
19
- clearCacheById(testCacheName);
20
17
  logger.resetStdout();
21
18
  logger.resetStderr();
22
19
  logger.writeOut = origWriteOut;