@effected/schemastore 0.9.1 → 0.11.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/CanonicalJson.js +38 -3
- package/CatalogEntry.js +6 -2
- package/DocumentDiff.js +8 -20
- package/DocumentLint.js +47 -14
- package/README.md +10 -2
- package/SchemaPipeline.js +4 -2
- package/SchemaTarget.js +2 -1
- package/SchemaVersioning.js +41 -19
- package/SchemastoreConfig.js +183 -67
- package/StoreDocument.js +55 -3
- package/index.d.ts +276 -52
- package/index.js +2 -2
- package/package.json +1 -1
package/CanonicalJson.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Effect, Result, Schema } from "effect";
|
|
1
|
+
import { Effect, JsonPointer, Result, Schema } from "effect";
|
|
2
2
|
|
|
3
3
|
//#region src/CanonicalJson.ts
|
|
4
4
|
/**
|
|
@@ -48,7 +48,24 @@ var SerializeFailure = class {
|
|
|
48
48
|
this.error = error;
|
|
49
49
|
}
|
|
50
50
|
};
|
|
51
|
-
const
|
|
51
|
+
const EQUALITY_STACK_GUARD = 256 * 8;
|
|
52
|
+
const isPlainRecord = (node) => {
|
|
53
|
+
if (typeof node !== "object" || node === null || Array.isArray(node)) return false;
|
|
54
|
+
const prototype = Object.getPrototypeOf(node);
|
|
55
|
+
return prototype === Object.prototype || prototype === null;
|
|
56
|
+
};
|
|
57
|
+
const contentEqual = (a, b, depth) => {
|
|
58
|
+
if (a === b) return true;
|
|
59
|
+
if (depth >= EQUALITY_STACK_GUARD) return false;
|
|
60
|
+
if (Array.isArray(a) || Array.isArray(b)) {
|
|
61
|
+
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
|
|
62
|
+
return a.every((element, index) => contentEqual(element, b[index], depth + 1));
|
|
63
|
+
}
|
|
64
|
+
if (!isPlainRecord(a) || !isPlainRecord(b)) return false;
|
|
65
|
+
const aKeys = Object.keys(a);
|
|
66
|
+
if (aKeys.length !== Object.keys(b).length) return false;
|
|
67
|
+
return aKeys.every((key) => Object.hasOwn(b, key) && contentEqual(a[key], b[key], depth + 1));
|
|
68
|
+
};
|
|
52
69
|
/**
|
|
53
70
|
* Deterministic, canonical JSON text: the package's owned serializer, so a
|
|
54
71
|
* consumer never shells out to an external formatter to produce a stable
|
|
@@ -89,6 +106,24 @@ var CanonicalJson = class CanonicalJson {
|
|
|
89
106
|
* primitive — synchronous callers can use that variant directly.
|
|
90
107
|
*/
|
|
91
108
|
static serialize = Effect.fn("CanonicalJson.serialize")((value, options) => Effect.fromResult(CanonicalJson.serializeResult(value, options)));
|
|
109
|
+
/**
|
|
110
|
+
* Content equality under the serializer's own semantics: two values are
|
|
111
|
+
* equal when they would parse to the same JSON document — object key
|
|
112
|
+
* order is a serialization detail and is ignored, array order is data
|
|
113
|
+
* and is not. `NaN` is never equal to itself (it is not JSON). The same
|
|
114
|
+
* reference compares `true` before any structural walk — so a cyclic
|
|
115
|
+
* value equals itself — and the comparison is total: two distinct
|
|
116
|
+
* cyclic or hostile-depth values report `false` at the stack guard
|
|
117
|
+
* rather than overflowing.
|
|
118
|
+
*
|
|
119
|
+
* This is the comparison `SchemaFile`'s write-if-changed and
|
|
120
|
+
* `DocumentDiff`'s leaf comparison already make, exported so a consumer
|
|
121
|
+
* writing its own JSON artifact (a catalog entry) can decide "unchanged"
|
|
122
|
+
* by the same rule instead of re-implementing it.
|
|
123
|
+
*/
|
|
124
|
+
static equals(left, right) {
|
|
125
|
+
return contentEqual(left, right, 0);
|
|
126
|
+
}
|
|
92
127
|
};
|
|
93
128
|
const indentUnit = (indent) => {
|
|
94
129
|
if (!Number.isInteger(indent) || indent < 0) throw new Error(`indent must be "tab" or a non-negative integer space count, got ${indent}`);
|
|
@@ -128,7 +163,7 @@ const emit = (value, path, depth, unit) => {
|
|
|
128
163
|
}));
|
|
129
164
|
const entries = Object.entries(value);
|
|
130
165
|
if (entries.length === 0) return "{}";
|
|
131
|
-
return `{\n${entries.map(([key, member]) => `${indent}${JSON.stringify(key)}: ${emit(member, `${path}/${
|
|
166
|
+
return `{\n${entries.map(([key, member]) => `${indent}${JSON.stringify(key)}: ${emit(member, `${path}/${JsonPointer.escapeToken(key)}`, depth + 1, unit)}`).join(",\n")}\n${closing}}`;
|
|
132
167
|
};
|
|
133
168
|
|
|
134
169
|
//#endregion
|
package/CatalogEntry.js
CHANGED
|
@@ -98,7 +98,9 @@ var CatalogEntry = class CatalogEntry extends Schema.Class("CatalogEntry")({
|
|
|
98
98
|
* Assembles an entry from a catalog identity plus
|
|
99
99
|
* {@link SchemaVersioning.catalogUrls}' inputs: pass `versions` for the
|
|
100
100
|
* versioned mode (the `versions` map and latest-pointing `url` are
|
|
101
|
-
* derived), omit it for the unversioned mode.
|
|
101
|
+
* derived), omit it for the unversioned mode. `layout` (default
|
|
102
|
+
* `"flat"`) and `current` (default: the newest label) are forwarded to
|
|
103
|
+
* {@link SchemaVersioning.catalogUrls} verbatim. Throws an `Error` naming
|
|
102
104
|
* both spellings when two labels compare equal under
|
|
103
105
|
* {@link SchemaVersioning.Order} (`1.2` and `1.2.0`): each would be its
|
|
104
106
|
* own key and URL for one document.
|
|
@@ -108,7 +110,9 @@ var CatalogEntry = class CatalogEntry extends Schema.Class("CatalogEntry")({
|
|
|
108
110
|
const urls = SchemaVersioning.catalogUrls({
|
|
109
111
|
baseUrl: options.baseUrl,
|
|
110
112
|
name: options.fileBaseName ?? options.name,
|
|
111
|
-
...options.versions !== void 0 ? { versions: options.versions } : {}
|
|
113
|
+
...options.versions !== void 0 ? { versions: options.versions } : {},
|
|
114
|
+
...options.layout !== void 0 ? { layout: options.layout } : {},
|
|
115
|
+
...options.current !== void 0 ? { current: options.current } : {}
|
|
112
116
|
});
|
|
113
117
|
return CatalogEntry.make({
|
|
114
118
|
name: options.name,
|
package/DocumentDiff.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { CanonicalJson } from "./CanonicalJson.js";
|
|
1
2
|
import { KeywordFamilies } from "./KeywordFamilies.js";
|
|
2
3
|
|
|
3
4
|
//#region src/DocumentDiff.ts
|
|
@@ -28,19 +29,6 @@ const SCHEMA_KEYWORDS = /* @__PURE__ */ new Set([
|
|
|
28
29
|
"not"
|
|
29
30
|
]);
|
|
30
31
|
const isSchemaObject = (node) => typeof node === "object" && node !== null && !Array.isArray(node);
|
|
31
|
-
const VALUE_COMPARISON_STACK_GUARD = 256 * 8;
|
|
32
|
-
const deepEqual = (a, b, depth) => {
|
|
33
|
-
if (a === b) return true;
|
|
34
|
-
if (depth >= VALUE_COMPARISON_STACK_GUARD) return false;
|
|
35
|
-
if (Array.isArray(a) || Array.isArray(b)) {
|
|
36
|
-
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
|
|
37
|
-
return a.every((element, index) => deepEqual(element, b[index], depth + 1));
|
|
38
|
-
}
|
|
39
|
-
if (!isSchemaObject(a) || !isSchemaObject(b)) return false;
|
|
40
|
-
const aKeys = Object.keys(a);
|
|
41
|
-
if (aKeys.length !== Object.keys(b).length) return false;
|
|
42
|
-
return aKeys.every((key) => Object.hasOwn(b, key) && deepEqual(a[key], b[key], depth + 1));
|
|
43
|
-
};
|
|
44
32
|
const worst = (left, right) => {
|
|
45
33
|
if (left === "contract" || right === "contract") return "contract";
|
|
46
34
|
if (left === "annotations" || right === "annotations") return "annotations";
|
|
@@ -49,8 +37,8 @@ const worst = (left, right) => {
|
|
|
49
37
|
const isAnnotationKey = (key) => DOCUMENTATION_KEYWORDS.has(key) || KeywordFamilies.isDeclared(key);
|
|
50
38
|
const compareSchema = (a, b, depth) => {
|
|
51
39
|
if (a === b) return "none";
|
|
52
|
-
if (depth >= 256) return
|
|
53
|
-
if (!isSchemaObject(a) || !isSchemaObject(b)) return
|
|
40
|
+
if (depth >= 256) return CanonicalJson.equals(a, b) ? "none" : "contract";
|
|
41
|
+
if (!isSchemaObject(a) || !isSchemaObject(b)) return CanonicalJson.equals(a, b) ? "none" : "contract";
|
|
54
42
|
let change = "none";
|
|
55
43
|
const keys = /* @__PURE__ */ new Set([...Object.keys(a), ...Object.keys(b)]);
|
|
56
44
|
for (const key of keys) {
|
|
@@ -62,7 +50,7 @@ const compareSchema = (a, b, depth) => {
|
|
|
62
50
|
const left = a[key];
|
|
63
51
|
const right = b[key];
|
|
64
52
|
if (isAnnotationKey(key)) {
|
|
65
|
-
change = worst(change,
|
|
53
|
+
change = worst(change, CanonicalJson.equals(left, right) ? "none" : "annotations");
|
|
66
54
|
continue;
|
|
67
55
|
}
|
|
68
56
|
if (SCHEMA_MAP_KEYWORDS.has(key)) change = worst(change, compareSchemaMap(left, right, depth + 1));
|
|
@@ -70,13 +58,13 @@ const compareSchema = (a, b, depth) => {
|
|
|
70
58
|
else if (SCHEMA_KEYWORDS.has(key)) change = worst(change, compareSchema(left, right, depth + 1));
|
|
71
59
|
else if (key === "items") change = worst(change, Array.isArray(left) || Array.isArray(right) ? compareSchemaArray(left, right, depth + 1) : compareSchema(left, right, depth + 1));
|
|
72
60
|
else if (key === "dependencies") change = worst(change, compareDependencies(left, right, depth + 1));
|
|
73
|
-
else change = worst(change,
|
|
61
|
+
else change = worst(change, CanonicalJson.equals(left, right) ? "none" : "contract");
|
|
74
62
|
if (change === "contract") return "contract";
|
|
75
63
|
}
|
|
76
64
|
return change;
|
|
77
65
|
};
|
|
78
66
|
const compareSchemaMap = (a, b, depth) => {
|
|
79
|
-
if (!isSchemaObject(a) || !isSchemaObject(b)) return
|
|
67
|
+
if (!isSchemaObject(a) || !isSchemaObject(b)) return CanonicalJson.equals(a, b) ? "none" : "contract";
|
|
80
68
|
let change = "none";
|
|
81
69
|
const names = /* @__PURE__ */ new Set([...Object.keys(a), ...Object.keys(b)]);
|
|
82
70
|
for (const name of names) {
|
|
@@ -96,14 +84,14 @@ const compareSchemaArray = (a, b, depth) => {
|
|
|
96
84
|
return change;
|
|
97
85
|
};
|
|
98
86
|
const compareDependencies = (a, b, depth) => {
|
|
99
|
-
if (!isSchemaObject(a) || !isSchemaObject(b)) return
|
|
87
|
+
if (!isSchemaObject(a) || !isSchemaObject(b)) return CanonicalJson.equals(a, b) ? "none" : "contract";
|
|
100
88
|
let change = "none";
|
|
101
89
|
const names = /* @__PURE__ */ new Set([...Object.keys(a), ...Object.keys(b)]);
|
|
102
90
|
for (const name of names) {
|
|
103
91
|
if (!Object.hasOwn(a, name) || !Object.hasOwn(b, name)) return "contract";
|
|
104
92
|
const left = a[name];
|
|
105
93
|
const right = b[name];
|
|
106
|
-
change = worst(change, Array.isArray(left) || Array.isArray(right) ?
|
|
94
|
+
change = worst(change, Array.isArray(left) || Array.isArray(right) ? CanonicalJson.equals(left, right) ? "none" : "contract" : compareSchema(left, right, depth + 1));
|
|
107
95
|
if (change === "contract") return "contract";
|
|
108
96
|
}
|
|
109
97
|
return change;
|
package/DocumentLint.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { KeywordFamilies } from "./KeywordFamilies.js";
|
|
2
|
-
import { Schema } from "effect";
|
|
2
|
+
import { JsonPointer, Schema } from "effect";
|
|
3
3
|
|
|
4
4
|
//#region src/DocumentLint.ts
|
|
5
5
|
/**
|
|
@@ -74,14 +74,20 @@ const DRAFT_07_KEYWORDS = /* @__PURE__ */ new Set([
|
|
|
74
74
|
"writeOnly"
|
|
75
75
|
]);
|
|
76
76
|
const URL_LINE = /^https?:\/\/\S+$/;
|
|
77
|
-
const escapePointerSegment = (segment) => segment.replace(/~/g, "~0").replace(/\//g, "~1");
|
|
78
|
-
const unescapePointerSegment = (segment) => segment.replace(/~1/g, "/").replace(/~0/g, "~");
|
|
79
77
|
const isSchemaObject = (node) => typeof node === "object" && node !== null && !Array.isArray(node);
|
|
78
|
+
const localPointer = (ref) => {
|
|
79
|
+
if (!ref.startsWith("#/")) return;
|
|
80
|
+
try {
|
|
81
|
+
return ref.slice(2).split("/").map((token) => JsonPointer.unescapeToken(decodeURIComponent(token)));
|
|
82
|
+
} catch {
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
};
|
|
80
86
|
const checkRef = (value, path, context) => {
|
|
81
87
|
if (typeof value !== "string") return;
|
|
82
88
|
if (value === "#") return;
|
|
83
|
-
const
|
|
84
|
-
if (
|
|
89
|
+
const pointer = localPointer(value);
|
|
90
|
+
if (pointer !== void 0 && pointer.length >= 2 && pointer[0] === "$defs" && Object.hasOwn(context.defs, pointer[1])) return;
|
|
85
91
|
context.findings.push(DocumentLintFinding.make({
|
|
86
92
|
check: "UnresolvedRef",
|
|
87
93
|
severity: "warning",
|
|
@@ -89,6 +95,24 @@ const checkRef = (value, path, context) => {
|
|
|
89
95
|
message: `$ref "${value}" does not resolve against the document's $defs pool`
|
|
90
96
|
}));
|
|
91
97
|
};
|
|
98
|
+
const describedNode = (document) => {
|
|
99
|
+
const keys = Object.keys(document.root);
|
|
100
|
+
if (keys.length !== 1 || keys[0] !== "$ref" || typeof document.root.$ref !== "string") return {
|
|
101
|
+
node: document.root,
|
|
102
|
+
path: ""
|
|
103
|
+
};
|
|
104
|
+
const pointer = localPointer(document.root.$ref);
|
|
105
|
+
if (pointer === void 0 || pointer.length !== 2 || pointer[0] !== "$defs") return {
|
|
106
|
+
node: document.root,
|
|
107
|
+
path: ""
|
|
108
|
+
};
|
|
109
|
+
const name = pointer[1];
|
|
110
|
+
const entry = Object.hasOwn(document.defs, name) ? document.defs[name] : void 0;
|
|
111
|
+
return isSchemaObject(entry) ? {
|
|
112
|
+
node: entry,
|
|
113
|
+
path: `/$defs/${JsonPointer.escapeToken(name)}`
|
|
114
|
+
} : void 0;
|
|
115
|
+
};
|
|
92
116
|
const lintSchema = (node, path, depth, context) => {
|
|
93
117
|
if (!isSchemaObject(node)) return;
|
|
94
118
|
if (depth >= 256) {
|
|
@@ -101,7 +125,7 @@ const lintSchema = (node, path, depth, context) => {
|
|
|
101
125
|
return;
|
|
102
126
|
}
|
|
103
127
|
for (const [key, value] of Object.entries(node)) {
|
|
104
|
-
const keyPath = `${path}/${
|
|
128
|
+
const keyPath = `${path}/${JsonPointer.escapeToken(key)}`;
|
|
105
129
|
if (!DRAFT_07_KEYWORDS.has(key) && !KeywordFamilies.isDeclared(key)) {
|
|
106
130
|
context.findings.push(DocumentLintFinding.make({
|
|
107
131
|
check: "UnknownKeyword",
|
|
@@ -119,11 +143,11 @@ const lintSchema = (node, path, depth, context) => {
|
|
|
119
143
|
case "patternProperties":
|
|
120
144
|
case "$defs":
|
|
121
145
|
case "definitions":
|
|
122
|
-
if (isSchemaObject(value)) for (const [name, subschema] of Object.entries(value)) lintSchema(subschema, `${keyPath}/${
|
|
146
|
+
if (isSchemaObject(value)) for (const [name, subschema] of Object.entries(value)) lintSchema(subschema, `${keyPath}/${JsonPointer.escapeToken(name)}`, depth + 1, context);
|
|
123
147
|
break;
|
|
124
148
|
case "dependencies":
|
|
125
149
|
if (isSchemaObject(value)) {
|
|
126
|
-
for (const [name, dependency] of Object.entries(value)) if (!Array.isArray(dependency)) lintSchema(dependency, `${keyPath}/${
|
|
150
|
+
for (const [name, dependency] of Object.entries(value)) if (!Array.isArray(dependency)) lintSchema(dependency, `${keyPath}/${JsonPointer.escapeToken(name)}`, depth + 1, context);
|
|
127
151
|
}
|
|
128
152
|
break;
|
|
129
153
|
case "items":
|
|
@@ -162,10 +186,18 @@ const lintSchema = (node, path, depth, context) => {
|
|
|
162
186
|
* non-standard families ({@link KeywordFamilies}: `x-taplo*`, `x-tombi-*`,
|
|
163
187
|
* `x-intellij-*`, `x-ai-*` and the vscode set), which ajv strict mode would reject.
|
|
164
188
|
* - `DescriptionWithoutUrl` — advisory: SchemaStore's description
|
|
165
|
-
* convention ends the root description with a docs URL line.
|
|
189
|
+
* convention ends the root description with a docs URL line. Read from
|
|
190
|
+
* the root, or from the `$defs` entry a bare local `$ref` root names —
|
|
191
|
+
* where assembly places a root annotation.
|
|
166
192
|
*
|
|
167
|
-
* Tractable because the input is bounded
|
|
168
|
-
*
|
|
193
|
+
* Tractable because the input is the bounded {@link StoreDocument} shape,
|
|
194
|
+
* not because assembly built it: the warning checks earn their keep on a
|
|
195
|
+
* document the pipeline did not build — hand-assembled through
|
|
196
|
+
* `StoreDocument.draft07`, or read back off disk. A local `$defs` pointer
|
|
197
|
+
* is decoded the way ajv decodes it, so no `$ref` into the pool that the
|
|
198
|
+
* engine gate resolves is reported `UnresolvedRef`; a `#/definitions/...`
|
|
199
|
+
* pointer stays a warning on purpose, since the pool lives under `$defs`.
|
|
200
|
+
* This is not a general JSON Schema validator.
|
|
169
201
|
*
|
|
170
202
|
* @public
|
|
171
203
|
*/
|
|
@@ -181,15 +213,16 @@ var DocumentLint = class {
|
|
|
181
213
|
findings: []
|
|
182
214
|
};
|
|
183
215
|
lintSchema(document.root, "", 0, context);
|
|
184
|
-
for (const [name, definition] of Object.entries(document.defs)) lintSchema(definition, `/$defs/${
|
|
185
|
-
const
|
|
216
|
+
for (const [name, definition] of Object.entries(document.defs)) lintSchema(definition, `/$defs/${JsonPointer.escapeToken(name)}`, 1, context);
|
|
217
|
+
const described = describedNode(document);
|
|
218
|
+
const description = described?.node.description;
|
|
186
219
|
if (typeof description === "string") {
|
|
187
220
|
const lines = description.split("\n");
|
|
188
221
|
const last = lines[lines.length - 1] ?? "";
|
|
189
222
|
if (!URL_LINE.test(last.trim())) context.findings.push(DocumentLintFinding.make({
|
|
190
223
|
check: "DescriptionWithoutUrl",
|
|
191
224
|
severity: "advisory",
|
|
192
|
-
path: "/description
|
|
225
|
+
path: `${described?.path ?? ""}/description`,
|
|
193
226
|
message: "SchemaStore's description convention ends with a documentation URL on its own line (<description>\\n<docs-url>)"
|
|
194
227
|
}));
|
|
195
228
|
}
|
package/README.md
CHANGED
|
@@ -320,6 +320,14 @@ The classification is key-order insensitive and keyword-position aware, like the
|
|
|
320
320
|
|
|
321
321
|
`CanonicalJson` is the deterministic serializer behind `serializeResult` and `SchemaFile.write`: insertion-order keys (assembly owns ordering — nothing is sorted), tab indentation by default, LF line endings and a single trailing newline, so equal documents serialize to equal bytes. Where `JSON.stringify` silently drops or rewrites `undefined`, `NaN` and non-plain objects, it fails typed instead — `NonJsonValueError` carries a JSON pointer to the offending value, and `JsonDepthExceededError` catches hostile nesting and cycles.
|
|
322
322
|
|
|
323
|
+
`CanonicalJson.equals` is content equality under the same semantics: two values are equal when they would parse to the same JSON document — object key order is ignored, arrays compare positionally, and a non-plain object (a class instance, a `Date`) compares by reference. It is the comparison `SchemaFile`'s write-if-changed and `DocumentDiff`'s leaf checks already make, exported so a consumer writing its own JSON artifact can decide "unchanged" by the same rule.
|
|
324
|
+
|
|
325
|
+
## Root annotations
|
|
326
|
+
|
|
327
|
+
Some annotations cannot be expressed on the source schema — a `Schema.Class` root's `title`, or a description on a field the generator filtered. `rootAnnotations` (on `StoreDocumentOptions`, and forwarded from `SchemaTarget.rootAnnotations` by the pipeline) merges them onto the emitted root after assembly. The gate is up front: only the standard annotation keywords (`title`, `description`, `$comment`, `default`, `examples`, `readOnly`, `writeOnly`, `contentMediaType`, `contentEncoding`) and the declared keyword families are admitted; anything else fails with `UndeclaredAnnotationKeyError` before generation, so the override cannot become a back door for assertion keywords.
|
|
328
|
+
|
|
329
|
+
Placement follows the assembled root: an inline root takes the annotations directly; a bare local `$ref` root whose `$defs` entry nothing else references takes them on that entry (Draft-07 validators ignore `$ref` siblings); a bare `$ref` root whose entry is shared — a recursive class — becomes `{ ...annotations, allOf: [{ $ref }] }`, so the document is annotated without every occurrence of the type inheriting its title.
|
|
330
|
+
|
|
323
331
|
## Features
|
|
324
332
|
|
|
325
333
|
- `StoreDocument` — the assembly pipeline: `fromSchema` / `fromSchemaResult`, the `draft07` constructor for hand-built documents, the flat `toJson()` publication shape, `serializeResult()`, the `DRAFT_07_META_SCHEMA` constant and `SchemaConversionError`.
|
|
@@ -331,8 +339,8 @@ The classification is key-order insensitive and keyword-position aware, like the
|
|
|
331
339
|
- `DocumentDiff` — `classify` puts two documents in `"none"` / `"annotations"` / `"contract"`, the signal for whether a change needs a new schema version, plus `isClean` for the clean case.
|
|
332
340
|
- `SchemaPipeline` — the emit loop over a target manifest, two-phase and all-or-nothing across targets: `run` and `check`, the single-target `runOne` and `checkOne`, `PipelineFinding`, `SchemaGateError` and an overridable gating predicate, plus the contract gate (`ContractChangePolicy`, `ContractChangeTarget`, `SchemaContractChangeError`, `PipelineCheckResult.contractBlocked`) that refuses to rewrite a published document's validation contract in place.
|
|
333
341
|
- `SchemaFile` — write-if-changed IO over core `FileSystem` / `Path`, comparing by content and answering what changed as a value; `check` is the non-writing drift half, answering `wouldWrite` alongside `change`.
|
|
334
|
-
- `SchemaTarget` — the target manifest vocabulary: schema, `$id`, destination path, an optional name, an optional version that requires one,
|
|
335
|
-
- `CanonicalJson` — the deterministic serializer with typed failures (`NonJsonValueError`, `JsonDepthExceededError`).
|
|
342
|
+
- `SchemaTarget` — the target manifest vocabulary: schema, `$id`, destination path, an optional name, an optional version that requires one, optional per-target `jsonSchema` generation options and `rootAnnotations` merged onto the emitted root.
|
|
343
|
+
- `CanonicalJson` — the deterministic serializer with typed failures (`NonJsonValueError`, `JsonDepthExceededError`) and `equals`, content equality under the same semantics.
|
|
336
344
|
|
|
337
345
|
## License
|
|
338
346
|
|
package/SchemaPipeline.js
CHANGED
|
@@ -197,7 +197,8 @@ var SchemaPipeline = class SchemaPipeline {
|
|
|
197
197
|
for (const target of targets) {
|
|
198
198
|
const document = yield* StoreDocument.fromSchema(target.schema, {
|
|
199
199
|
$id: target.$id,
|
|
200
|
-
...target.jsonSchema !== void 0 ? { jsonSchema: target.jsonSchema } : {}
|
|
200
|
+
...target.jsonSchema !== void 0 ? { jsonSchema: target.jsonSchema } : {},
|
|
201
|
+
...target.rootAnnotations !== void 0 ? { rootAnnotations: target.rootAnnotations } : {}
|
|
201
202
|
});
|
|
202
203
|
const findings = yield* gather(document, options);
|
|
203
204
|
yield* gate(target, findings, options);
|
|
@@ -253,7 +254,8 @@ var SchemaPipeline = class SchemaPipeline {
|
|
|
253
254
|
for (const target of targets) {
|
|
254
255
|
const document = yield* StoreDocument.fromSchema(target.schema, {
|
|
255
256
|
$id: target.$id,
|
|
256
|
-
...target.jsonSchema !== void 0 ? { jsonSchema: target.jsonSchema } : {}
|
|
257
|
+
...target.jsonSchema !== void 0 ? { jsonSchema: target.jsonSchema } : {},
|
|
258
|
+
...target.rootAnnotations !== void 0 ? { rootAnnotations: target.rootAnnotations } : {}
|
|
257
259
|
});
|
|
258
260
|
const findings = yield* gather(document, options);
|
|
259
261
|
const { wouldWrite, change } = yield* files.check(target.path, document, options?.write);
|
package/SchemaTarget.js
CHANGED
|
@@ -29,7 +29,8 @@ var SchemaTarget = class {
|
|
|
29
29
|
published: options.published ?? false,
|
|
30
30
|
...options.name !== void 0 ? { name: options.name } : {},
|
|
31
31
|
...version !== void 0 ? { version } : {},
|
|
32
|
-
...options.jsonSchema !== void 0 ? { jsonSchema: options.jsonSchema } : {}
|
|
32
|
+
...options.jsonSchema !== void 0 ? { jsonSchema: options.jsonSchema } : {},
|
|
33
|
+
...options.rootAnnotations !== void 0 ? { rootAnnotations: options.rootAnnotations } : {}
|
|
33
34
|
};
|
|
34
35
|
}
|
|
35
36
|
};
|
package/SchemaVersioning.js
CHANGED
|
@@ -73,7 +73,7 @@ const bumpNext = (current, parsed, components) => {
|
|
|
73
73
|
}
|
|
74
74
|
};
|
|
75
75
|
const assertSimpleName = (name) => {
|
|
76
|
-
if (
|
|
76
|
+
if (!SchemaVersioning.isSimpleName(name)) throw new Error(`Schema name must be a non-empty simple file base name, got "${name}"`);
|
|
77
77
|
};
|
|
78
78
|
const joinUrl = (baseUrl, file) => {
|
|
79
79
|
let end = baseUrl.length;
|
|
@@ -182,22 +182,35 @@ var SchemaVersioning = class SchemaVersioning {
|
|
|
182
182
|
return reparsed.success;
|
|
183
183
|
}
|
|
184
184
|
/**
|
|
185
|
+
* Whether a name is a simple file base name — non-empty, no path
|
|
186
|
+
* separators, no whitespace — the rule every schema name is held to
|
|
187
|
+
* ({@link SchemaVersioning.fileName} throws on anything else; `defineConfig`
|
|
188
|
+
* rejects a schema key the same way). One predicate so the two cannot drift.
|
|
189
|
+
*/
|
|
190
|
+
static isSimpleName(name) {
|
|
191
|
+
return name.length > 0 && !/[/\\\s]/.test(name);
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
185
194
|
* Derives the schema file name for a catalog name: `name.json`
|
|
186
|
-
* unversioned, `name-<version>.json` versioned
|
|
195
|
+
* unversioned, `name-<version>.json` versioned under the `"flat"`
|
|
196
|
+
* layout (the default and the only shape SchemaStore serves), or
|
|
197
|
+
* `<version>/name-<version>.json` under `"versioned"`.
|
|
187
198
|
*
|
|
188
199
|
* The name must be a simple file base name (no separators, no
|
|
189
200
|
* whitespace); anything else is a wiring mistake and throws.
|
|
190
201
|
*/
|
|
191
|
-
static fileName(name, version) {
|
|
202
|
+
static fileName(name, version, layout = "flat") {
|
|
192
203
|
assertSimpleName(name);
|
|
193
|
-
|
|
204
|
+
if (version === void 0) return `${name}.json`;
|
|
205
|
+
const file = `${name}-${version}.json`;
|
|
206
|
+
return layout === "versioned" ? `${version}/${file}` : file;
|
|
194
207
|
}
|
|
195
208
|
/**
|
|
196
209
|
* The canonical URL a schema file is hosted at: `baseUrl` joined with
|
|
197
210
|
* {@link SchemaVersioning.fileName}.
|
|
198
211
|
*/
|
|
199
|
-
static schemaUrl(baseUrl, name, version) {
|
|
200
|
-
return joinUrl(baseUrl, SchemaVersioning.fileName(name, version));
|
|
212
|
+
static schemaUrl(baseUrl, name, version, layout = "flat") {
|
|
213
|
+
return joinUrl(baseUrl, SchemaVersioning.fileName(name, version, layout));
|
|
201
214
|
}
|
|
202
215
|
/**
|
|
203
216
|
* Assembles the `url`/`versions` half of a catalog entry.
|
|
@@ -205,29 +218,38 @@ var SchemaVersioning = class SchemaVersioning {
|
|
|
205
218
|
* Omitting `versions` selects the unversioned mode (`url` only,
|
|
206
219
|
* pointing at the plain `name.json`). Providing them selects the
|
|
207
220
|
* versioned mode: the `versions` map carries every label, and `url`
|
|
208
|
-
* points at
|
|
221
|
+
* points at `current` (default: the newest label under
|
|
222
|
+
* {@link SchemaVersioning.Order}). An **empty** `versions` array is
|
|
209
223
|
* a contradiction (versioned mode with no versions) and throws — pass
|
|
210
|
-
* `undefined` for the unversioned mode.
|
|
224
|
+
* `undefined` for the unversioned mode. `current`, when given, must
|
|
225
|
+
* compare equal under {@link SchemaVersioning.Order} to a member of
|
|
226
|
+
* `versions` or this throws; `url` is built from that member's own
|
|
227
|
+
* spelling (the `versions` map's key), not from the `current` argument
|
|
228
|
+
* verbatim — so a differently-spelled equivalent (`"1.2"` matching a
|
|
229
|
+
* `"1.2.0"` member) still points `url` at the same file the map does.
|
|
230
|
+
*
|
|
231
|
+
* `layout` (default `"flat"`) is forwarded to every URL derivation, so
|
|
232
|
+
* `"versioned"` nests every map value and `url` under its own version
|
|
233
|
+
* directory.
|
|
211
234
|
*
|
|
212
|
-
* Labels are inserted in ascending {@link SchemaVersioning.Order}
|
|
213
|
-
*
|
|
214
|
-
*
|
|
215
|
-
* `versions` map is not meaningful when such a label is present. A two-
|
|
216
|
-
* or three-component label is never integer-like and keeps insertion
|
|
217
|
-
* order through serialization. Deriving ordering from the labels
|
|
218
|
-
* themselves (as {@link SchemaVersioning.latest} does) is still the
|
|
219
|
-
* robust read.
|
|
235
|
+
* Labels are inserted in ascending {@link SchemaVersioning.Order}; see
|
|
236
|
+
* {@link CatalogUrls.versions} for why a bare-major key's serialized
|
|
237
|
+
* position is not meaningful.
|
|
220
238
|
*/
|
|
221
239
|
static catalogUrls(options) {
|
|
222
240
|
const { baseUrl, name, versions } = options;
|
|
223
|
-
|
|
241
|
+
const layout = options.layout ?? "flat";
|
|
242
|
+
if (versions === void 0) return { url: SchemaVersioning.schemaUrl(baseUrl, name, void 0, layout) };
|
|
224
243
|
if (versions.length === 0) throw new Error(`catalogUrls received an empty versions array for "${name}": pass undefined for the unversioned mode`);
|
|
225
244
|
const ascending = [...versions].sort(SchemaVersioning.Order);
|
|
226
245
|
const map = {};
|
|
227
|
-
for (const version of ascending) map[version] = SchemaVersioning.schemaUrl(baseUrl, name, version);
|
|
246
|
+
for (const version of ascending) map[version] = SchemaVersioning.schemaUrl(baseUrl, name, version, layout);
|
|
228
247
|
const newest = ascending[ascending.length - 1];
|
|
248
|
+
const requested = options.current ?? newest;
|
|
249
|
+
const current = ascending.find((v) => SchemaVersioning.Order(v, requested) === 0);
|
|
250
|
+
if (current === void 0) throw new Error(`catalogUrls: current "${requested}" is not one of the versions of "${name}"`);
|
|
229
251
|
return {
|
|
230
|
-
url: SchemaVersioning.schemaUrl(baseUrl, name,
|
|
252
|
+
url: SchemaVersioning.schemaUrl(baseUrl, name, current, layout),
|
|
231
253
|
versions: map
|
|
232
254
|
};
|
|
233
255
|
}
|