@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.
@@ -1,42 +1,147 @@
1
1
  import { SchemaVersioning } from "./SchemaVersioning.js";
2
2
  import { CatalogEntry } from "./CatalogEntry.js";
3
3
  import { DriftPolicy } from "./DriftPolicy.js";
4
- import { Schema } from "effect";
4
+ import { SchemaTarget } from "./SchemaTarget.js";
5
+ import { Option, Predicate, Result, Schema } from "effect";
5
6
 
6
7
  //#region src/SchemastoreConfig.ts
7
8
  const ConfigBrand = Symbol.for("@effected/schemastore/SchemastoreConfig");
8
- const DriftSchema = Schema.Struct({
9
- policy: Schema.optionalKey(Schema.Literals([
10
- "strict",
11
- "semantic",
12
- "allow"
13
- ])),
14
- onDrift: Schema.optionalKey(Schema.Literals(["error", "warn"]))
15
- });
16
- const CatalogConfigSchema = Schema.Struct({
17
- name: Schema.String.check(Schema.isMinLength(1)),
18
- description: Schema.String,
19
- fileMatch: Schema.Array(Schema.String),
20
- baseUrl: Schema.String.check(Schema.isMinLength(1)),
21
- path: Schema.String.check(Schema.isMinLength(1))
22
- });
23
- const decodeOrThrow = (schema, value, what) => {
24
- const result = Schema.decodeUnknownResult(schema)(value);
25
- if (result._tag === "Failure") throw new Error(`defineConfig: invalid ${what}: ${String(result.failure)}`);
26
- return result.success;
9
+ /** The host SchemaStore-hosted documents declare in `$id`. @public */
10
+ const SCHEMASTORE_ID_BASE = "https://json.schemastore.org";
11
+ /** The host SchemaStore's `catalog.json` points `url` at. @public */
12
+ const SCHEMASTORE_CATALOG_BASE = "https://www.schemastore.org";
13
+ const DRIFT_TOLERANCES = [
14
+ "strict",
15
+ "semantic",
16
+ "allow"
17
+ ];
18
+ const ON_DRIFT = ["error", "warn"];
19
+ const LAYOUTS = ["flat", "versioned"];
20
+ const fail = (message) => {
21
+ throw new Error(`defineConfig: ${message}`);
27
22
  };
28
- const versionsByName = (schemas) => {
29
- const map = /* @__PURE__ */ new Map();
30
- for (const target of schemas) {
31
- if (target.name === void 0 || target.version === void 0) continue;
32
- const version = target.version;
33
- const versions = map.get(target.name) ?? [];
23
+ const trimSlashes = (dir) => {
24
+ let end = dir.length;
25
+ while (end > 1 && dir.charCodeAt(end - 1) === 47) end -= 1;
26
+ return dir.slice(0, end);
27
+ };
28
+ const resolveHosting = (name, baseUrl, layout) => {
29
+ if (baseUrl === void 0) return fail(`schema "${name}" has no baseUrl and the config declares no default`);
30
+ if (typeof baseUrl !== "string") return fail(`schema "${name}" has a baseUrl that is not a string`);
31
+ if (baseUrl.length === 0) return fail(`schema "${name}" has no baseUrl and the config declares no default`);
32
+ if (layout !== void 0 && !LAYOUTS.includes(layout)) return fail(`schema "${name}" has an invalid layout "${String(layout)}"; expected "flat" or "versioned"`);
33
+ if (baseUrl === "schemastore") {
34
+ if (layout !== void 0) return fail(`schema "${name}" declares layout "${layout}" under baseUrl "schemastore", which serves only the flat layout`);
35
+ return {
36
+ idBase: SCHEMASTORE_ID_BASE,
37
+ catalogBase: SCHEMASTORE_CATALOG_BASE,
38
+ layout: "flat"
39
+ };
40
+ }
41
+ if (!baseUrl.startsWith("https://") || baseUrl.length === 8) return fail(`schema "${name}" has baseUrl "${baseUrl}"; expected "schemastore" or an https:// URL`);
42
+ return {
43
+ idBase: baseUrl,
44
+ catalogBase: baseUrl,
45
+ layout: layout ?? "versioned"
46
+ };
47
+ };
48
+ const parseLabel = (name, label) => {
49
+ if (typeof label !== "string") return fail(`schema "${name}" has a version label that is not a string: ${String(label)}`);
50
+ return Result.getOrThrowWith(SchemaVersioning.parseResult(label), (error) => /* @__PURE__ */ new Error(`defineConfig: schema "${name}" has an invalid version label "${label}": ${error.message}`));
51
+ };
52
+ const resolveVersions = (name, entry) => {
53
+ if (entry.versions === void 0) {
54
+ if (entry.current !== void 0) return fail(`schema "${name}" declares current "${entry.current}" without versions`);
55
+ return;
56
+ }
57
+ if (!Array.isArray(entry.versions)) return fail(`schema "${name}" declares versions that is not an array`);
58
+ if (entry.versions.length === 0) return fail(`schema "${name}" declares versions as an empty array; omit versions for an unversioned schema`);
59
+ const versions = [];
60
+ for (const label of entry.versions) {
61
+ const version = parseLabel(name, label);
34
62
  const duplicate = versions.find((v) => SchemaVersioning.Order(v, version) === 0);
35
- if (duplicate !== void 0) throw new Error(`defineConfig: schema "${target.name}" declares the same version twice, as "${duplicate}" and "${version}"`);
63
+ if (duplicate !== void 0) return fail(`schema "${name}" declares the same version twice, as "${duplicate}" and "${version}"`);
36
64
  versions.push(version);
37
- map.set(target.name, versions);
38
65
  }
39
- return map;
66
+ const newest = Option.getOrThrow(SchemaVersioning.latest(versions));
67
+ if (entry.current === void 0) return {
68
+ versions,
69
+ current: newest
70
+ };
71
+ const current = parseLabel(name, entry.current);
72
+ const match = versions.find((v) => SchemaVersioning.Order(v, current) === 0);
73
+ if (match === void 0) return fail(`schema "${name}" declares current "${entry.current}" which is not one of its versions`);
74
+ return {
75
+ versions,
76
+ current: match
77
+ };
78
+ };
79
+ const isDriftTolerance = (value) => DRIFT_TOLERANCES.includes(value);
80
+ const resolveConfigDrift = (value) => {
81
+ if (value === void 0) return DriftPolicy.defaults.policy;
82
+ return isDriftTolerance(value) ? value : fail(`config has an invalid drift tolerance "${String(value)}"`);
83
+ };
84
+ const resolveSchemaDrift = (name, value, fallback) => {
85
+ if (value === void 0) return fallback;
86
+ return isDriftTolerance(value) ? value : fail(`schema "${name}" has an invalid drift tolerance "${String(value)}"`);
87
+ };
88
+ const resolveEntry = (name, entry, defaults, outputDir) => {
89
+ if (!SchemaVersioning.isSimpleName(name)) return fail(`schema "${name}" must be keyed by a simple file base name (no separators, no whitespace)`);
90
+ if (!Predicate.isObject(entry)) return fail(`schema "${name}" must be an object`);
91
+ if (!Schema.isSchema(entry.schema)) return fail(`schema "${name}" has a schema that is not an Effect Schema`);
92
+ if (entry.published !== void 0 && typeof entry.published !== "boolean") return fail(`schema "${name}" has a published that is not a boolean`);
93
+ const baseUrl = entry.baseUrl ?? defaults.baseUrl;
94
+ const hosting = resolveHosting(name, baseUrl, entry.layout);
95
+ const versioned = resolveVersions(name, entry);
96
+ if (baseUrl === "schemastore" && entry.catalog === void 0) return fail(`schema "${name}" must declare a catalog block under baseUrl "schemastore"`);
97
+ if (entry.catalog !== void 0 && (!Predicate.isObject(entry.catalog) || !Array.isArray(entry.catalog.fileMatch) || typeof entry.catalog.description !== "string")) return fail(`schema "${name}" declares an invalid catalog block (expected { description, fileMatch[] })`);
98
+ if (entry.catalog !== void 0 && entry.catalog.fileMatch.length === 0) return fail(`schema "${name}" declares a catalog with an empty fileMatch`);
99
+ const file = (version) => `${outputDir}/${SchemaVersioning.fileName(name, version, hosting.layout)}`;
100
+ const urlOf = (base) => (version) => SchemaVersioning.schemaUrl(base, name, version, hosting.layout);
101
+ const idOf = urlOf(hosting.idBase);
102
+ const catalogUrlOf = urlOf(hosting.catalogBase);
103
+ const current = versioned?.current;
104
+ const target = current === void 0 ? SchemaTarget.make({
105
+ schema: entry.schema,
106
+ $id: idOf(current),
107
+ name,
108
+ path: file(current),
109
+ published: entry.published ?? false,
110
+ ...entry.jsonSchema !== void 0 ? { jsonSchema: entry.jsonSchema } : {},
111
+ ...entry.rootAnnotations !== void 0 ? { rootAnnotations: entry.rootAnnotations } : {}
112
+ }) : SchemaTarget.make({
113
+ schema: entry.schema,
114
+ $id: idOf(current),
115
+ name,
116
+ path: file(current),
117
+ version: current,
118
+ published: entry.published ?? false,
119
+ ...entry.jsonSchema !== void 0 ? { jsonSchema: entry.jsonSchema } : {},
120
+ ...entry.rootAnnotations !== void 0 ? { rootAnnotations: entry.rootAnnotations } : {}
121
+ });
122
+ const frozen = versioned === void 0 ? [] : versioned.versions.filter((v) => v !== versioned.current).map((version) => ({
123
+ version,
124
+ path: file(version),
125
+ url: catalogUrlOf(version)
126
+ }));
127
+ const catalog = entry.catalog === void 0 ? void 0 : CatalogEntry.assemble({
128
+ name,
129
+ description: entry.catalog.description,
130
+ fileMatch: entry.catalog.fileMatch,
131
+ baseUrl: hosting.catalogBase,
132
+ layout: hosting.layout,
133
+ ...versioned !== void 0 ? {
134
+ versions: versioned.versions,
135
+ current: versioned.current
136
+ } : {}
137
+ });
138
+ return {
139
+ name,
140
+ target,
141
+ frozen,
142
+ drift: resolveSchemaDrift(name, entry.drift, defaults.drift),
143
+ ...catalog !== void 0 ? { catalog } : {}
144
+ };
40
145
  };
41
146
  const normalizePath = (raw) => {
42
147
  const absolute = raw.startsWith("/");
@@ -52,11 +157,11 @@ const normalizePath = (raw) => {
52
157
  }
53
158
  return `${absolute ? "/" : ""}${out.join("/")}`;
54
159
  };
55
- const assertUniquePaths = (schemas, catalog) => {
160
+ const assertUniquePaths = (paths) => {
56
161
  const seen = /* @__PURE__ */ new Set();
57
- for (const p of [...schemas.map((target) => target.path), ...catalog.map((entry) => entry.path)]) {
162
+ for (const p of paths) {
58
163
  const normalized = normalizePath(p);
59
- if (seen.has(normalized)) throw new Error(`defineConfig: output path "${p}" is declared twice`);
164
+ if (seen.has(normalized)) fail(`output path "${p}" is declared twice`);
60
165
  seen.add(normalized);
61
166
  }
62
167
  };
@@ -64,46 +169,57 @@ const assertUniquePaths = (schemas, catalog) => {
64
169
  * Validate and assemble a `schemastore.config.ts` value.
65
170
  *
66
171
  * @remarks
67
- * Pure: no IO, no Effect. Identity-with-validation over the input, filling
68
- * drift defaults, deriving each catalog entry's `versions` from EVERY
69
- * versioned schema of that name (published or not — the entry is what gets
70
- * submitted to become published), and branding the result so a loader can
71
- * recognise a config module's default export. Throws a plain `Error` on a
72
- * bad input; the CLI wraps it into its typed config-load error. Rejects an
73
- * output `path` declared twice across schemas and catalog entries, compared
74
- * after a lexical normalisation (`./`, `..`, trailing `/`); the CLI's loader
75
- * re-checks on the resolved absolute paths.
172
+ * Pure: no IO, no Effect. `$id`, the write `path` and every catalog URL are
173
+ * derived from ONE layout (`outputDir`, `baseUrl` and `layout`) so they
174
+ * cannot disagree with each other. `versions` names every label a schema
175
+ * advertises; `current` (default: the newest under
176
+ * {@link SchemaVersioning.Order}) is the one generated at `target`, and every
177
+ * other label becomes a {@link FrozenVersion} the CLI verifies but does not
178
+ * regenerate. `baseUrl: "schemastore"` expands to
179
+ * {@link SCHEMASTORE_ID_BASE} for `$id` and {@link SCHEMASTORE_CATALOG_BASE}
180
+ * for the catalog URL, forcing the `"flat"` layout; any other `baseUrl` is
181
+ * used as one base for both, defaulting to the `"versioned"` layout.
182
+ *
183
+ * Throws a plain `Error` (never a raw `TypeError`) naming the offending
184
+ * schema on: a non-object `input`; an empty `schemas` record; a
185
+ * missing/empty `outputDir`; a schema key that is not a simple file base
186
+ * name; a schema whose `schema` is not an Effect Schema; a schema with no
187
+ * `baseUrl` anywhere, or a `baseUrl` that is not a string; a `baseUrl` that
188
+ * is neither `"schemastore"` nor an `https://` URL; a `versions` that is not
189
+ * an array, or an empty `versions` array; a version label (or `current`)
190
+ * that is not a string, or an otherwise invalid version label; two labels
191
+ * spelling the same version; `current` given without `versions`, or naming
192
+ * one not among them; a non-boolean `published`; `layout` declared under
193
+ * `baseUrl: "schemastore"`; a missing `catalog` under
194
+ * `baseUrl: "schemastore"`, or one with an empty `fileMatch`; an invalid
195
+ * `drift` or top-level `onDrift`; and an output path (a target, a frozen
196
+ * file, or the catalog path) declared twice, compared after a lexical
197
+ * normalisation (`./`, `..`, trailing `/`) — the CLI's loader re-checks on
198
+ * the resolved absolute paths. Branding the result lets a loader recognise a
199
+ * config module's default export via {@link isSchemastoreConfig}.
76
200
  *
77
201
  * @public
78
202
  */
79
203
  const defineConfig = (input) => {
80
- if (!Array.isArray(input.schemas) || input.schemas.length === 0) throw new Error("defineConfig: at least one schema is required");
81
- const drift = decodeOrThrow(DriftSchema, input.drift ?? {}, "drift block");
82
- const versions = versionsByName(input.schemas);
83
- const catalog = (input.catalog ?? []).map((raw) => {
84
- const config = decodeOrThrow(CatalogConfigSchema, raw, `catalog entry "${String(raw.name)}"`);
85
- const found = versions.get(config.name);
86
- if (found === void 0) throw new Error(`defineConfig: catalog entry "${config.name}" matches no versioned schema`);
87
- return {
88
- config,
89
- entry: CatalogEntry.assemble({
90
- name: config.name,
91
- description: config.description,
92
- fileMatch: config.fileMatch,
93
- baseUrl: config.baseUrl,
94
- versions: found
95
- })
96
- };
97
- });
98
- assertUniquePaths(input.schemas, catalog.map((c) => c.config));
204
+ if (!Predicate.isObject(input)) return fail("expected a config object");
205
+ if (typeof input.outputDir !== "string" || input.outputDir.length === 0) return fail("outputDir is required");
206
+ const outputDir = trimSlashes(input.outputDir);
207
+ if (!Predicate.isObject(input.schemas) || Object.keys(input.schemas).length === 0) return fail("at least one schema is required");
208
+ if (input.onDrift !== void 0 && !ON_DRIFT.includes(input.onDrift)) return fail(`invalid onDrift "${String(input.onDrift)}"`);
209
+ if (input.catalogPath !== void 0 && (typeof input.catalogPath !== "string" || input.catalogPath.length === 0)) return fail("catalogPath must be a non-empty string when given");
210
+ const defaults = {
211
+ baseUrl: input.baseUrl,
212
+ drift: resolveConfigDrift(input.drift)
213
+ };
214
+ const schemas = Object.entries(input.schemas).map(([name, entry]) => resolveEntry(name, entry, defaults, outputDir));
215
+ const catalogPath = input.catalogPath ?? `${outputDir}/catalog.json`;
216
+ assertUniquePaths([...schemas.flatMap((s) => [s.target.path, ...s.frozen.map((f) => f.path)]), catalogPath]);
99
217
  return {
100
218
  [ConfigBrand]: true,
101
- schemas: input.schemas,
102
- catalog,
103
- drift: {
104
- policy: drift.policy ?? DriftPolicy.defaults.policy,
105
- onDrift: drift.onDrift ?? DriftPolicy.defaults.onDrift
106
- }
219
+ outputDir,
220
+ onDrift: input.onDrift ?? DriftPolicy.defaults.onDrift,
221
+ catalogPath,
222
+ schemas
107
223
  };
108
224
  };
109
225
  /**
@@ -115,4 +231,4 @@ const defineConfig = (input) => {
115
231
  const isSchemastoreConfig = (value) => typeof value === "object" && value !== null && value[ConfigBrand] === true;
116
232
 
117
233
  //#endregion
118
- export { defineConfig, isSchemastoreConfig };
234
+ export { SCHEMASTORE_CATALOG_BASE, SCHEMASTORE_ID_BASE, defineConfig, isSchemastoreConfig };
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,