@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 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 escapePointerSegment = (segment) => segment.replace(/~/g, "~0").replace(/\//g, "~1");
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}/${escapePointerSegment(key)}`, depth + 1, unit)}`).join(",\n")}\n${closing}}`;
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 deepEqual(a, b, 0) ? "none" : "contract";
53
- if (!isSchemaObject(a) || !isSchemaObject(b)) return deepEqual(a, b, depth) ? "none" : "contract";
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, deepEqual(left, right, depth) ? "none" : "annotations");
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, deepEqual(left, right, depth) ? "none" : "contract");
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 deepEqual(a, b, depth) ? "none" : "contract";
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 deepEqual(a, b, depth) ? "none" : "contract";
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) ? deepEqual(left, right, depth) ? "none" : "contract" : compareSchema(left, right, depth + 1));
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 match = /^#\/\$defs\/([^/]+)/.exec(value);
84
- if (match !== null && Object.hasOwn(context.defs, unescapePointerSegment(match[1]))) return;
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}/${escapePointerSegment(key)}`;
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}/${escapePointerSegment(name)}`, depth + 1, context);
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}/${escapePointerSegment(name)}`, depth + 1, context);
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 `toJsonSchemaDocument` output;
168
- * this is not a general JSON Schema validator.
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/${escapePointerSegment(name)}`, 1, context);
185
- const description = document.root.description;
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, and optional per-target `jsonSchema` generation options.
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
  };
@@ -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}, but a
213
- * bare-major label (`"2"`) is array-index-like, so JavaScript enumerates
214
- * it FIRST regardless of insertion order — the serialized order of the
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;
@@ -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 catalog = (input.catalog ?? []).map((raw) => {
84
- const config = decodeOrThrow(CatalogConfigSchema, raw, `catalog entry "${String(raw.name)}"`);
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}, but a
730
- * bare-major label (`"2"`) is array-index-like, so JavaScript enumerates
731
- * it FIRST regardless of insertion order — the serialized order of the
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
- * Tractable because the input is bounded `toJsonSchemaDocument` output;
851
- * this is not a general JSON Schema validator.
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.9.1",
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": [