@effected/schemastore 0.9.1 → 0.10.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/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 +3 -8
- package/SchemastoreConfig.js +5 -2
- package/StoreDocument.js +55 -3
- package/index.d.ts +78 -14
- 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/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
|
@@ -209,14 +209,9 @@ var SchemaVersioning = class SchemaVersioning {
|
|
|
209
209
|
* a contradiction (versioned mode with no versions) and throws — pass
|
|
210
210
|
* `undefined` for the unversioned mode.
|
|
211
211
|
*
|
|
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.
|
|
212
|
+
* Labels are inserted in ascending {@link SchemaVersioning.Order}; see
|
|
213
|
+
* {@link CatalogUrls.versions} for why a bare-major key's serialized
|
|
214
|
+
* position is not meaningful.
|
|
220
215
|
*/
|
|
221
216
|
static catalogUrls(options) {
|
|
222
217
|
const { baseUrl, name, versions } = options;
|
package/SchemastoreConfig.js
CHANGED
|
@@ -80,8 +80,11 @@ const defineConfig = (input) => {
|
|
|
80
80
|
if (!Array.isArray(input.schemas) || input.schemas.length === 0) throw new Error("defineConfig: at least one schema is required");
|
|
81
81
|
const drift = decodeOrThrow(DriftSchema, input.drift ?? {}, "drift block");
|
|
82
82
|
const versions = versionsByName(input.schemas);
|
|
83
|
-
const
|
|
84
|
-
|
|
83
|
+
const rawCatalog = input.catalog ?? [];
|
|
84
|
+
if (!Array.isArray(rawCatalog)) throw new Error("defineConfig: invalid catalog: expected an array of catalog entries");
|
|
85
|
+
const catalog = rawCatalog.map((raw) => {
|
|
86
|
+
const label = typeof raw === "object" && raw !== null ? String(raw.name) : String(raw);
|
|
87
|
+
const config = decodeOrThrow(CatalogConfigSchema, raw, `catalog entry "${label}"`);
|
|
85
88
|
const found = versions.get(config.name);
|
|
86
89
|
if (found === void 0) throw new Error(`defineConfig: catalog entry "${config.name}" matches no versioned schema`);
|
|
87
90
|
return {
|
package/StoreDocument.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { CanonicalJson } from "./CanonicalJson.js";
|
|
2
2
|
import { KeywordFamilies } from "./KeywordFamilies.js";
|
|
3
|
-
import { Effect, JsonSchema, Result, Schema } from "effect";
|
|
3
|
+
import { Effect, JsonPointer, JsonSchema, Result, Schema } from "effect";
|
|
4
4
|
|
|
5
5
|
//#region src/StoreDocument.ts
|
|
6
6
|
/**
|
|
@@ -36,7 +36,10 @@ var SchemaConversionError = class extends Schema.TaggedError()("SchemaConversion
|
|
|
36
36
|
/**
|
|
37
37
|
* Indicates that a caller-supplied `includeAnnotationKey` admitted an
|
|
38
38
|
* annotation key outside the declared keyword families
|
|
39
|
-
* ({@link KeywordFamilies})
|
|
39
|
+
* ({@link KeywordFamilies}), or that a
|
|
40
|
+
* {@link StoreDocumentOptions.rootAnnotations} override names a key outside
|
|
41
|
+
* the admitted set (the standard annotation keywords plus the declared
|
|
42
|
+
* families).
|
|
40
43
|
*
|
|
41
44
|
* Raised by {@link StoreDocument.fromSchema}. This package emits
|
|
42
45
|
* SchemaStore-compatible documents only, so the declared families are the
|
|
@@ -47,7 +50,8 @@ var SchemaConversionError = class extends Schema.TaggedError()("SchemaConversion
|
|
|
47
50
|
*
|
|
48
51
|
* The predicate itself cannot be introspected, so the offending keys are
|
|
49
52
|
* the ones it actually admitted while the document was being generated: a
|
|
50
|
-
* key the source schema never annotates cannot appear here.
|
|
53
|
+
* key the source schema never annotates cannot appear here. Override keys,
|
|
54
|
+
* by contrast, are checked up front, before anything is generated.
|
|
51
55
|
*
|
|
52
56
|
* @public
|
|
53
57
|
*/
|
|
@@ -63,6 +67,48 @@ var UndeclaredAnnotationKeyError = class extends Schema.TaggedError()("Undeclare
|
|
|
63
67
|
}
|
|
64
68
|
};
|
|
65
69
|
const DEFINITIONS_REF_PREFIX = /^#\/definitions(?=\/|$)/;
|
|
70
|
+
const STANDARD_ANNOTATION_KEYWORDS = /* @__PURE__ */ new Set([
|
|
71
|
+
"title",
|
|
72
|
+
"description",
|
|
73
|
+
"$comment",
|
|
74
|
+
"default",
|
|
75
|
+
"examples",
|
|
76
|
+
"readOnly",
|
|
77
|
+
"writeOnly",
|
|
78
|
+
"contentMediaType",
|
|
79
|
+
"contentEncoding"
|
|
80
|
+
]);
|
|
81
|
+
const countRefs = (node, ref) => {
|
|
82
|
+
if (Array.isArray(node)) {
|
|
83
|
+
let count = 0;
|
|
84
|
+
for (const item of node) count += countRefs(item, ref);
|
|
85
|
+
return count;
|
|
86
|
+
}
|
|
87
|
+
if (typeof node === "object" && node !== null) {
|
|
88
|
+
let count = 0;
|
|
89
|
+
for (const [key, value] of Object.entries(node)) {
|
|
90
|
+
if (KeywordFamilies.isDeclared(key)) continue;
|
|
91
|
+
count += key === "$ref" && value === ref ? 1 : countRefs(value, ref);
|
|
92
|
+
}
|
|
93
|
+
return count;
|
|
94
|
+
}
|
|
95
|
+
return 0;
|
|
96
|
+
};
|
|
97
|
+
const applyRootAnnotations = (root, defs, annotations) => {
|
|
98
|
+
const keys = Object.keys(root);
|
|
99
|
+
const ref = keys.length === 1 && keys[0] === "$ref" ? root.$ref : void 0;
|
|
100
|
+
const path = typeof ref === "string" ? JsonPointer.parseUriFragment(ref) : void 0;
|
|
101
|
+
const pool = path !== void 0 && path.length === 2 && path[0] === "$defs" ? defs[path[1]] : void 0;
|
|
102
|
+
const entry = typeof pool === "object" && pool !== null && !Array.isArray(pool) ? pool : void 0;
|
|
103
|
+
const shared = entry !== void 0 && typeof ref === "string" && countRefs(defs, ref) > 0;
|
|
104
|
+
if (shared) delete root.$ref;
|
|
105
|
+
const target = entry !== void 0 && !shared ? entry : root;
|
|
106
|
+
for (const [key, value] of Object.entries(annotations)) {
|
|
107
|
+
if (value === void 0) continue;
|
|
108
|
+
target[key] = value;
|
|
109
|
+
}
|
|
110
|
+
if (shared) root.allOf = [{ $ref: ref }];
|
|
111
|
+
};
|
|
66
112
|
var RewriteDepthExceeded = class {
|
|
67
113
|
_tag = "RewriteDepthExceeded";
|
|
68
114
|
};
|
|
@@ -153,6 +199,11 @@ var StoreDocument = class StoreDocument extends Schema.Class("StoreDocument")({
|
|
|
153
199
|
*/
|
|
154
200
|
static fromSchemaResult(source, options) {
|
|
155
201
|
try {
|
|
202
|
+
const refusedOverrides = Object.keys(options.rootAnnotations ?? {}).filter((key) => !STANDARD_ANNOTATION_KEYWORDS.has(key) && !KeywordFamilies.isDeclared(key));
|
|
203
|
+
if (refusedOverrides.length > 0) return Result.fail(UndeclaredAnnotationKeyError.make({
|
|
204
|
+
$id: options.$id,
|
|
205
|
+
keys: [...refusedOverrides].sort()
|
|
206
|
+
}));
|
|
156
207
|
const userIncludes = options.jsonSchema?.includeAnnotationKey;
|
|
157
208
|
const undeclared = /* @__PURE__ */ new Set();
|
|
158
209
|
const document = Schema.toJsonSchemaDocument(source, {
|
|
@@ -171,6 +222,7 @@ var StoreDocument = class StoreDocument extends Schema.Class("StoreDocument")({
|
|
|
171
222
|
const root = restoreDefsRefs(lowered.schema, 0);
|
|
172
223
|
const defs = Object.create(null);
|
|
173
224
|
for (const [name, definition] of Object.entries(lowered.definitions)) defs[name] = restoreDefsRefs(definition, 1);
|
|
225
|
+
if (options.rootAnnotations !== void 0) applyRootAnnotations(root, defs, options.rootAnnotations);
|
|
174
226
|
return Result.succeed(StoreDocument.make({
|
|
175
227
|
$schema: DRAFT_07_META_SCHEMA,
|
|
176
228
|
$id: options.$id,
|
package/index.d.ts
CHANGED
|
@@ -94,6 +94,22 @@ export declare class CanonicalJson {
|
|
|
94
94
|
* primitive — synchronous callers can use that variant directly.
|
|
95
95
|
*/
|
|
96
96
|
static readonly serialize: (value: unknown, options?: CanonicalJsonOptions | undefined) => Effect.Effect<string, CanonicalJsonError, never>;
|
|
97
|
+
/**
|
|
98
|
+
* Content equality under the serializer's own semantics: two values are
|
|
99
|
+
* equal when they would parse to the same JSON document — object key
|
|
100
|
+
* order is a serialization detail and is ignored, array order is data
|
|
101
|
+
* and is not. `NaN` is never equal to itself (it is not JSON). The same
|
|
102
|
+
* reference compares `true` before any structural walk — so a cyclic
|
|
103
|
+
* value equals itself — and the comparison is total: two distinct
|
|
104
|
+
* cyclic or hostile-depth values report `false` at the stack guard
|
|
105
|
+
* rather than overflowing.
|
|
106
|
+
*
|
|
107
|
+
* This is the comparison `SchemaFile`'s write-if-changed and
|
|
108
|
+
* `DocumentDiff`'s leaf comparison already make, exported so a consumer
|
|
109
|
+
* writing its own JSON artifact (a catalog entry) can decide "unchanged"
|
|
110
|
+
* by the same rule instead of re-implementing it.
|
|
111
|
+
*/
|
|
112
|
+
static equals(left: unknown, right: unknown): boolean;
|
|
97
113
|
}
|
|
98
114
|
//#endregion
|
|
99
115
|
//#region src/DocumentDiff.d.ts
|
|
@@ -213,7 +229,10 @@ declare const UndeclaredAnnotationKeyError_base: Schema.Class<UndeclaredAnnotati
|
|
|
213
229
|
/**
|
|
214
230
|
* Indicates that a caller-supplied `includeAnnotationKey` admitted an
|
|
215
231
|
* annotation key outside the declared keyword families
|
|
216
|
-
* ({@link KeywordFamilies})
|
|
232
|
+
* ({@link KeywordFamilies}), or that a
|
|
233
|
+
* {@link StoreDocumentOptions.rootAnnotations} override names a key outside
|
|
234
|
+
* the admitted set (the standard annotation keywords plus the declared
|
|
235
|
+
* families).
|
|
217
236
|
*
|
|
218
237
|
* Raised by {@link StoreDocument.fromSchema}. This package emits
|
|
219
238
|
* SchemaStore-compatible documents only, so the declared families are the
|
|
@@ -224,7 +243,8 @@ declare const UndeclaredAnnotationKeyError_base: Schema.Class<UndeclaredAnnotati
|
|
|
224
243
|
*
|
|
225
244
|
* The predicate itself cannot be introspected, so the offending keys are
|
|
226
245
|
* the ones it actually admitted while the document was being generated: a
|
|
227
|
-
* key the source schema never annotates cannot appear here.
|
|
246
|
+
* key the source schema never annotates cannot appear here. Override keys,
|
|
247
|
+
* by contrast, are checked up front, before anything is generated.
|
|
228
248
|
*
|
|
229
249
|
* @public
|
|
230
250
|
*/
|
|
@@ -258,6 +278,39 @@ interface StoreDocumentOptions {
|
|
|
258
278
|
* `ToJsonSchemaOptions` passes through, and is best left unset.
|
|
259
279
|
*/
|
|
260
280
|
readonly jsonSchema?: Schema.ToJsonSchemaOptions;
|
|
281
|
+
/**
|
|
282
|
+
* Annotations merged onto the emitted document's root after assembly —
|
|
283
|
+
* the escape hatch for a generator-side annotation loss the source
|
|
284
|
+
* schema cannot express (a filtered field, or a root whose annotations
|
|
285
|
+
* core does not carry).
|
|
286
|
+
*
|
|
287
|
+
* Admitted keys are the standard JSON Schema annotation keywords
|
|
288
|
+
* (`title`, `description`, `$comment`, `default`, `examples`,
|
|
289
|
+
* `readOnly`, `writeOnly`, `contentMediaType`, `contentEncoding`) and
|
|
290
|
+
* the declared keyword families
|
|
291
|
+
* ({@link KeywordFamilies}); any other key fails the build with
|
|
292
|
+
* {@link UndeclaredAnnotationKeyError}, so the override cannot become a
|
|
293
|
+
* back door for assertion keywords. Override keys win over generated
|
|
294
|
+
* ones. The override is checked before generation, so when both this
|
|
295
|
+
* gate and the `includeAnnotationKey` gate would fire, the override's
|
|
296
|
+
* keys are the ones reported and the predicate's are not.
|
|
297
|
+
*
|
|
298
|
+
* Placement follows the assembled root's shape, in three cases:
|
|
299
|
+
*
|
|
300
|
+
* - An inline root (a `Struct`, a primitive) takes the annotations
|
|
301
|
+
* directly.
|
|
302
|
+
* - A bare local `$ref` root (the shape a `Schema.Class` root produces —
|
|
303
|
+
* `{ "$ref": "#/$defs/FooEncoded" }`) whose `$defs` entry has no
|
|
304
|
+
* other referent takes them on that entry instead: Draft-07
|
|
305
|
+
* validators ignore `$ref` siblings, so the root would carry them
|
|
306
|
+
* nowhere.
|
|
307
|
+
* - A bare local `$ref` root whose entry is shared — a recursive class,
|
|
308
|
+
* or one another definition also references — is replaced by
|
|
309
|
+
* `{ ...annotations, allOf: [{ "$ref": ... }] }`, so the document is
|
|
310
|
+
* annotated without every other occurrence of the type inheriting
|
|
311
|
+
* the root's title.
|
|
312
|
+
*/
|
|
313
|
+
readonly rootAnnotations?: Readonly<Record<string, unknown>>;
|
|
261
314
|
}
|
|
262
315
|
declare const StoreDocument_base: Schema.Class<StoreDocument, Schema.Struct<{
|
|
263
316
|
/** The meta-schema URL ({@link DRAFT_07_META_SCHEMA}). */
|
|
@@ -726,14 +779,9 @@ export declare class SchemaVersioning {
|
|
|
726
779
|
* a contradiction (versioned mode with no versions) and throws — pass
|
|
727
780
|
* `undefined` for the unversioned mode.
|
|
728
781
|
*
|
|
729
|
-
* Labels are inserted in ascending {@link SchemaVersioning.Order}
|
|
730
|
-
*
|
|
731
|
-
*
|
|
732
|
-
* `versions` map is not meaningful when such a label is present. A two-
|
|
733
|
-
* or three-component label is never integer-like and keeps insertion
|
|
734
|
-
* order through serialization. Deriving ordering from the labels
|
|
735
|
-
* themselves (as {@link SchemaVersioning.latest} does) is still the
|
|
736
|
-
* robust read.
|
|
782
|
+
* Labels are inserted in ascending {@link SchemaVersioning.Order}; see
|
|
783
|
+
* {@link CatalogUrls.versions} for why a bare-major key's serialized
|
|
784
|
+
* position is not meaningful.
|
|
737
785
|
*/
|
|
738
786
|
static catalogUrls(options: {
|
|
739
787
|
readonly baseUrl: string;
|
|
@@ -845,10 +893,18 @@ export declare class DocumentLintFinding extends DocumentLintFinding_base {}
|
|
|
845
893
|
* non-standard families ({@link KeywordFamilies}: `x-taplo*`, `x-tombi-*`,
|
|
846
894
|
* `x-intellij-*`, `x-ai-*` and the vscode set), which ajv strict mode would reject.
|
|
847
895
|
* - `DescriptionWithoutUrl` — advisory: SchemaStore's description
|
|
848
|
-
* convention ends the root description with a docs URL line.
|
|
849
|
-
*
|
|
850
|
-
*
|
|
851
|
-
*
|
|
896
|
+
* convention ends the root description with a docs URL line. Read from
|
|
897
|
+
* the root, or from the `$defs` entry a bare local `$ref` root names —
|
|
898
|
+
* where assembly places a root annotation.
|
|
899
|
+
*
|
|
900
|
+
* Tractable because the input is the bounded {@link StoreDocument} shape,
|
|
901
|
+
* not because assembly built it: the warning checks earn their keep on a
|
|
902
|
+
* document the pipeline did not build — hand-assembled through
|
|
903
|
+
* `StoreDocument.draft07`, or read back off disk. A local `$defs` pointer
|
|
904
|
+
* is decoded the way ajv decodes it, so no `$ref` into the pool that the
|
|
905
|
+
* engine gate resolves is reported `UnresolvedRef`; a `#/definitions/...`
|
|
906
|
+
* pointer stays a warning on purpose, since the pool lives under `$defs`.
|
|
907
|
+
* This is not a general JSON Schema validator.
|
|
852
908
|
*
|
|
853
909
|
* @public
|
|
854
910
|
*/
|
|
@@ -1068,6 +1124,12 @@ export interface SchemaTarget {
|
|
|
1068
1124
|
* `includeAnnotationKey` gate this option is also subject to.
|
|
1069
1125
|
*/
|
|
1070
1126
|
readonly jsonSchema?: Schema.ToJsonSchemaOptions;
|
|
1127
|
+
/**
|
|
1128
|
+
* Forwarded to {@link StoreDocumentOptions.rootAnnotations}; a
|
|
1129
|
+
* target-level field for the same self-describing reason as
|
|
1130
|
+
* `jsonSchema`.
|
|
1131
|
+
*/
|
|
1132
|
+
readonly rootAnnotations?: Readonly<Record<string, unknown>>;
|
|
1071
1133
|
}
|
|
1072
1134
|
/**
|
|
1073
1135
|
* Constructors for `SchemaTarget` values.
|
|
@@ -1087,6 +1149,7 @@ export declare class SchemaTarget {
|
|
|
1087
1149
|
readonly path: string;
|
|
1088
1150
|
readonly published?: boolean;
|
|
1089
1151
|
readonly jsonSchema?: Schema.ToJsonSchemaOptions;
|
|
1152
|
+
readonly rootAnnotations?: Readonly<Record<string, unknown>>;
|
|
1090
1153
|
}): SchemaTarget;
|
|
1091
1154
|
/**
|
|
1092
1155
|
* Builds a versioned target. `name` is **required** here: versioned
|
|
@@ -1103,6 +1166,7 @@ export declare class SchemaTarget {
|
|
|
1103
1166
|
readonly version: SchemaVersion | string;
|
|
1104
1167
|
readonly published?: boolean;
|
|
1105
1168
|
readonly jsonSchema?: Schema.ToJsonSchemaOptions;
|
|
1169
|
+
readonly rootAnnotations?: Readonly<Record<string, unknown>>;
|
|
1106
1170
|
}): SchemaTarget;
|
|
1107
1171
|
}
|
|
1108
1172
|
//#endregion
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@effected/schemastore",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Build, validate, version and publish SchemaStore-shaped Draft-07 JSON Schema documents from Effect Schema sources: document assembly, ajv strict-mode validation, structural lints, catalog entries, canonical JSON and a content-comparing emit pipeline.",
|
|
6
6
|
"keywords": [
|