@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 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/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. Throws an `Error` naming
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 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
  };
@@ -73,7 +73,7 @@ const bumpNext = (current, parsed, components) => {
73
73
  }
74
74
  };
75
75
  const assertSimpleName = (name) => {
76
- if (name.length === 0 || /[/\\\s]/.test(name)) throw new Error(`Schema name must be a non-empty simple file base name, got "${name}"`);
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
- return version === void 0 ? `${name}.json` : `${name}-${version}.json`;
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 the latest version's file. An **empty** `versions` array is
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}, 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.
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
- if (versions === void 0) return { url: SchemaVersioning.schemaUrl(baseUrl, name) };
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, newest),
252
+ url: SchemaVersioning.schemaUrl(baseUrl, name, current, layout),
231
253
  versions: map
232
254
  };
233
255
  }