@effected/schemastore 0.10.0 → 0.12.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.
@@ -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,39 @@ 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"` — `<version>/name.json`
198
+ * when `appendVersion` is `false`, which only the versioned layout can
199
+ * carry (under `"flat"` two versions would share one file name, so that
200
+ * combination throws).
187
201
  *
188
202
  * The name must be a simple file base name (no separators, no
189
203
  * whitespace); anything else is a wiring mistake and throws.
190
204
  */
191
- static fileName(name, version) {
205
+ static fileName(name, version, layout = "flat", appendVersion = true) {
192
206
  assertSimpleName(name);
193
- return version === void 0 ? `${name}.json` : `${name}-${version}.json`;
207
+ if (!appendVersion && layout !== "versioned") throw new Error(`appendVersion: false requires the "versioned" layout; under "${layout}" every version of "${name}" would share one file name`);
208
+ if (version === void 0) return `${name}.json`;
209
+ const file = appendVersion ? `${name}-${version}.json` : `${name}.json`;
210
+ return layout === "versioned" ? `${version}/${file}` : file;
194
211
  }
195
212
  /**
196
213
  * The canonical URL a schema file is hosted at: `baseUrl` joined with
197
214
  * {@link SchemaVersioning.fileName}.
198
215
  */
199
- static schemaUrl(baseUrl, name, version) {
200
- return joinUrl(baseUrl, SchemaVersioning.fileName(name, version));
216
+ static schemaUrl(baseUrl, name, version, layout = "flat", appendVersion = true) {
217
+ return joinUrl(baseUrl, SchemaVersioning.fileName(name, version, layout, appendVersion));
201
218
  }
202
219
  /**
203
220
  * Assembles the `url`/`versions` half of a catalog entry.
@@ -205,9 +222,20 @@ var SchemaVersioning = class SchemaVersioning {
205
222
  * Omitting `versions` selects the unversioned mode (`url` only,
206
223
  * pointing at the plain `name.json`). Providing them selects the
207
224
  * versioned mode: the `versions` map carries every label, and `url`
208
- * points at the latest version's file. An **empty** `versions` array is
225
+ * points at `current` (default: the newest label under
226
+ * {@link SchemaVersioning.Order}). An **empty** `versions` array is
209
227
  * a contradiction (versioned mode with no versions) and throws — pass
210
- * `undefined` for the unversioned mode.
228
+ * `undefined` for the unversioned mode. `current`, when given, must
229
+ * compare equal under {@link SchemaVersioning.Order} to a member of
230
+ * `versions` or this throws; `url` is built from that member's own
231
+ * spelling (the `versions` map's key), not from the `current` argument
232
+ * verbatim — so a differently-spelled equivalent (`"1.2"` matching a
233
+ * `"1.2.0"` member) still points `url` at the same file the map does.
234
+ *
235
+ * `layout` (default `"flat"`) and `appendVersion` (default `true`) are
236
+ * forwarded to every URL derivation, so `"versioned"` nests every map
237
+ * value and `url` under its own version directory, with or without the
238
+ * `-<version>` file suffix.
211
239
  *
212
240
  * Labels are inserted in ascending {@link SchemaVersioning.Order}; see
213
241
  * {@link CatalogUrls.versions} for why a bare-major key's serialized
@@ -215,14 +243,19 @@ var SchemaVersioning = class SchemaVersioning {
215
243
  */
216
244
  static catalogUrls(options) {
217
245
  const { baseUrl, name, versions } = options;
218
- if (versions === void 0) return { url: SchemaVersioning.schemaUrl(baseUrl, name) };
246
+ const layout = options.layout ?? "flat";
247
+ const appendVersion = options.appendVersion ?? true;
248
+ if (versions === void 0) return { url: SchemaVersioning.schemaUrl(baseUrl, name, void 0, layout, appendVersion) };
219
249
  if (versions.length === 0) throw new Error(`catalogUrls received an empty versions array for "${name}": pass undefined for the unversioned mode`);
220
250
  const ascending = [...versions].sort(SchemaVersioning.Order);
221
251
  const map = {};
222
- for (const version of ascending) map[version] = SchemaVersioning.schemaUrl(baseUrl, name, version);
252
+ for (const version of ascending) map[version] = SchemaVersioning.schemaUrl(baseUrl, name, version, layout, appendVersion);
223
253
  const newest = ascending[ascending.length - 1];
254
+ const requested = options.current ?? newest;
255
+ const current = ascending.find((v) => SchemaVersioning.Order(v, requested) === 0);
256
+ if (current === void 0) throw new Error(`catalogUrls: current "${requested}" is not one of the versions of "${name}"`);
224
257
  return {
225
- url: SchemaVersioning.schemaUrl(baseUrl, name, newest),
258
+ url: SchemaVersioning.schemaUrl(baseUrl, name, current, layout, appendVersion),
226
259
  versions: map
227
260
  };
228
261
  }
@@ -1,42 +1,146 @@
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 { HostedSchema } from "./HostedSchema.js";
5
+ import { SchemaTarget } from "./SchemaTarget.js";
6
+ import { Predicate, Result, Schema } from "effect";
5
7
 
6
8
  //#region src/SchemastoreConfig.ts
7
9
  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)),
10
+ const fail = (message) => {
11
+ throw new Error(`defineConfig: ${message}`);
12
+ };
13
+ const trimSlashes = (dir) => {
14
+ let end = dir.length;
15
+ while (end > 1 && dir.charCodeAt(end - 1) === 47) end -= 1;
16
+ return dir.slice(0, end);
17
+ };
18
+ const members = (record) => Object.keys(record);
19
+ const DriftToleranceInput = Schema.Literals(members({
20
+ strict: null,
21
+ semantic: null,
22
+ allow: null
23
+ }));
24
+ const OnDriftInput = Schema.Literals(members({
25
+ error: null,
26
+ warn: null
27
+ }));
28
+ const LayoutInput = Schema.Literals(members({
29
+ flat: null,
30
+ versioned: null
31
+ }));
32
+ const EffectSchemaInput = Schema.declare((u) => Schema.isSchema(u), { expected: "an Effect Schema" });
33
+ const OptionsInput = Schema.declare((u) => Predicate.isObject(u), { expected: "an object" });
34
+ const CatalogBlockInput = Schema.Struct({
18
35
  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))
36
+ fileMatch: Schema.NonEmptyArray(Schema.String)
37
+ });
38
+ const EntryInput = Schema.Struct({
39
+ schema: EffectSchemaInput,
40
+ hosted: Schema.optionalKey(Schema.instanceOf(HostedSchema, { expected: "a HostedSchema" })),
41
+ versions: Schema.optionalKey(Schema.Array(Schema.String)),
42
+ current: Schema.optionalKey(Schema.String),
43
+ published: Schema.optionalKey(Schema.Boolean),
44
+ baseUrl: Schema.optionalKey(Schema.String),
45
+ layout: Schema.optionalKey(LayoutInput),
46
+ appendVersion: Schema.optionalKey(Schema.Boolean),
47
+ drift: Schema.optionalKey(DriftToleranceInput),
48
+ catalog: Schema.optionalKey(CatalogBlockInput),
49
+ jsonSchema: Schema.optionalKey(OptionsInput),
50
+ rootAnnotations: Schema.optionalKey(OptionsInput)
22
51
  });
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;
52
+ const ConfigInput = Schema.Struct({
53
+ outputDir: Schema.NonEmptyString,
54
+ baseUrl: Schema.optionalKey(Schema.String),
55
+ drift: Schema.optionalKey(DriftToleranceInput),
56
+ onDrift: Schema.optionalKey(OnDriftInput),
57
+ catalogPath: Schema.optionalKey(Schema.NonEmptyString),
58
+ schemas: Schema.Record(Schema.String, Schema.Unknown)
59
+ });
60
+ const DECODE_OPTIONS = {
61
+ onExcessProperty: "error",
62
+ errors: "all"
27
63
  };
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) ?? [];
34
- 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}"`);
36
- versions.push(version);
37
- map.set(target.name, versions);
64
+ const withoutUndefined = (input) => Predicate.isObject(input) && !Array.isArray(input) ? Object.fromEntries(Object.entries(input).filter(([, value]) => value !== void 0)) : input;
65
+ const decodeOrThrow = (schema, input, prefix) => Result.getOrThrowWith(Schema.decodeUnknownResult(schema)(input, DECODE_OPTIONS), (error) => /* @__PURE__ */ new Error(`defineConfig: ${prefix}${error.message.replace(/\n\s*/g, " ")}`));
66
+ const resolveIdentity = (name, entry, defaultBaseUrl) => {
67
+ if (entry.hosted !== void 0) {
68
+ if (entry.hosted.name !== name) return fail(`schema "${name}" is keyed differently from its hosted identity "${entry.hosted.name}"`);
69
+ const spelled = [
70
+ "baseUrl",
71
+ "versions",
72
+ "current",
73
+ "layout",
74
+ "appendVersion"
75
+ ].filter((key) => entry[key] !== void 0);
76
+ if (spelled.length > 0) return fail(`schema "${name}" declares ${spelled.map((key) => `"${key}"`).join(", ")} beside hosted; the hosted identity owns them`);
77
+ return entry.hosted;
38
78
  }
39
- return map;
79
+ const baseUrl = entry.baseUrl ?? defaultBaseUrl;
80
+ if (baseUrl === void 0) return fail(`schema "${name}" has no baseUrl and the config declares no default`);
81
+ const decoded = Schema.decodeUnknownResult(HostedSchema)(withoutUndefined({
82
+ name,
83
+ baseUrl,
84
+ versions: entry.versions,
85
+ current: entry.current,
86
+ layout: entry.layout,
87
+ appendVersion: entry.appendVersion
88
+ }));
89
+ return Result.getOrThrowWith(decoded, (error) => /* @__PURE__ */ new Error(`defineConfig: ${error.message.replace(/\n\s*/g, " ")}`));
90
+ };
91
+ const resolveEntry = (name, input, defaults, outputDir) => {
92
+ if (!SchemaVersioning.isSimpleName(name)) return fail(`schema "${name}" must be keyed by a simple file base name (no separators, no whitespace)`);
93
+ const entry = decodeOrThrow(EntryInput, withoutUndefined(input), `schema "${name}" `);
94
+ const hosted = resolveIdentity(name, entry, defaults.baseUrl);
95
+ if (hosted.baseUrl === "schemastore" && entry.catalog === void 0) return fail(`schema "${name}" must declare a catalog block under baseUrl "schemastore"`);
96
+ const versions = hosted.resolvedVersions;
97
+ const current = hosted.resolvedCurrent;
98
+ const file = (version) => `${outputDir}/${hosted.fileNameFor(version)}`;
99
+ const generation = {
100
+ ...entry.jsonSchema !== void 0 ? { jsonSchema: entry.jsonSchema } : {},
101
+ ...entry.rootAnnotations !== void 0 ? { rootAnnotations: entry.rootAnnotations } : {}
102
+ };
103
+ const target = current === void 0 ? SchemaTarget.make({
104
+ schema: entry.schema,
105
+ $id: hosted.idFor(current),
106
+ name,
107
+ path: file(current),
108
+ published: entry.published ?? false,
109
+ ...generation
110
+ }) : SchemaTarget.make({
111
+ schema: entry.schema,
112
+ $id: hosted.idFor(current),
113
+ name,
114
+ path: file(current),
115
+ version: current,
116
+ published: entry.published ?? false,
117
+ ...generation
118
+ });
119
+ const frozen = versions.filter((v) => v !== current).map((version) => ({
120
+ version,
121
+ path: file(version),
122
+ $id: hosted.idFor(version),
123
+ url: hosted.urlFor(version)
124
+ }));
125
+ const catalog = entry.catalog === void 0 ? void 0 : CatalogEntry.assemble({
126
+ name,
127
+ description: entry.catalog.description,
128
+ fileMatch: entry.catalog.fileMatch,
129
+ baseUrl: hosted.catalogBase,
130
+ layout: hosted.resolvedLayout,
131
+ appendVersion: hosted.resolvedAppendVersion,
132
+ ...current !== void 0 ? {
133
+ versions,
134
+ current
135
+ } : {}
136
+ });
137
+ return {
138
+ name,
139
+ target,
140
+ frozen,
141
+ drift: entry.drift ?? defaults.drift,
142
+ ...catalog !== void 0 ? { catalog } : {}
143
+ };
40
144
  };
41
145
  const normalizePath = (raw) => {
42
146
  const absolute = raw.startsWith("/");
@@ -52,11 +156,11 @@ const normalizePath = (raw) => {
52
156
  }
53
157
  return `${absolute ? "/" : ""}${out.join("/")}`;
54
158
  };
55
- const assertUniquePaths = (schemas, catalog) => {
159
+ const assertUniquePaths = (paths) => {
56
160
  const seen = /* @__PURE__ */ new Set();
57
- for (const p of [...schemas.map((target) => target.path), ...catalog.map((entry) => entry.path)]) {
161
+ for (const p of paths) {
58
162
  const normalized = normalizePath(p);
59
- if (seen.has(normalized)) throw new Error(`defineConfig: output path "${p}" is declared twice`);
163
+ if (seen.has(normalized)) fail(`output path "${p}" is declared twice`);
60
164
  seen.add(normalized);
61
165
  }
62
166
  };
@@ -64,49 +168,54 @@ const assertUniquePaths = (schemas, catalog) => {
64
168
  * Validate and assemble a `schemastore.config.ts` value.
65
169
  *
66
170
  * @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.
171
+ * Pure: no IO, no Effect. `$id`, the write `path` and every catalog URL are
172
+ * derived from ONE layout (`outputDir`, `baseUrl` and `layout`) so they
173
+ * cannot disagree with each other. `versions` names every label a schema
174
+ * advertises; `current` (default: the newest under
175
+ * {@link SchemaVersioning.Order}) is the one generated at `target`, and every
176
+ * other label becomes a {@link FrozenVersion} the CLI verifies but does not
177
+ * regenerate. `baseUrl: "schemastore"` expands to
178
+ * {@link SCHEMASTORE_ID_BASE} for `$id` and {@link SCHEMASTORE_CATALOG_BASE}
179
+ * for the catalog URL, forcing the `"flat"` layout; any other `baseUrl` is
180
+ * used as one base for both, defaulting to the `"versioned"` layout.
181
+ *
182
+ * Throws a plain `Error` (never a raw `TypeError`) on every malformed
183
+ * input. The shape is decoded once per level with a `Schema.Struct`
184
+ * (`errors: "all"`, so every issue on an entry is reported at once, and
185
+ * `onExcessProperty: "error"`, so a typo'd key is named rather than
186
+ * dropped); the message is `defineConfig: schema "<name>" ` followed by the
187
+ * decode issues (`Expected string at ["baseUrl"]`). After a shape-clean
188
+ * decode the cross-field rules run: the entry's identity — its
189
+ * {@link HostedSchema}, or one built from `baseUrl`/`versions`/`current`/
190
+ * `layout` and the config default — is validated by `HostedSchema` itself
191
+ * (a `hosted` entry must be keyed by `hosted.name` and must not spell those
192
+ * four fields beside it); a schema key must be a simple file base name; a
193
+ * `catalog` is required under `baseUrl: "schemastore"`; an empty `schemas`
194
+ * record is rejected; and an output path (a target, a frozen file, or the
195
+ * catalog path) declared twice is rejected after a lexical normalisation
196
+ * (`./`, `..`, trailing `/`) — the CLI's loader re-checks on the resolved
197
+ * absolute paths. Branding the result lets a loader recognise a config
198
+ * module's default export via {@link isSchemastoreConfig}.
76
199
  *
77
200
  * @public
78
201
  */
79
202
  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 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}"`);
88
- const found = versions.get(config.name);
89
- if (found === void 0) throw new Error(`defineConfig: catalog entry "${config.name}" matches no versioned schema`);
90
- return {
91
- config,
92
- entry: CatalogEntry.assemble({
93
- name: config.name,
94
- description: config.description,
95
- fileMatch: config.fileMatch,
96
- baseUrl: config.baseUrl,
97
- versions: found
98
- })
99
- };
100
- });
101
- assertUniquePaths(input.schemas, catalog.map((c) => c.config));
203
+ const config = decodeOrThrow(ConfigInput, withoutUndefined(input), "");
204
+ const outputDir = trimSlashes(config.outputDir);
205
+ if (Object.keys(config.schemas).length === 0) return fail("at least one schema is required");
206
+ const defaults = {
207
+ baseUrl: config.baseUrl,
208
+ drift: config.drift ?? DriftPolicy.defaults.policy
209
+ };
210
+ const schemas = Object.entries(config.schemas).map(([name, entry]) => resolveEntry(name, entry, defaults, outputDir));
211
+ const catalogPath = config.catalogPath ?? `${outputDir}/catalog.json`;
212
+ assertUniquePaths([...schemas.flatMap((s) => [s.target.path, ...s.frozen.map((f) => f.path)]), catalogPath]);
102
213
  return {
103
214
  [ConfigBrand]: true,
104
- schemas: input.schemas,
105
- catalog,
106
- drift: {
107
- policy: drift.policy ?? DriftPolicy.defaults.policy,
108
- onDrift: drift.onDrift ?? DriftPolicy.defaults.onDrift
109
- }
215
+ outputDir,
216
+ onDrift: config.onDrift ?? DriftPolicy.defaults.onDrift,
217
+ catalogPath,
218
+ schemas
110
219
  };
111
220
  };
112
221
  /**
package/StoreDocument.js CHANGED
@@ -207,6 +207,7 @@ var StoreDocument = class StoreDocument extends Schema.Class("StoreDocument")({
207
207
  const userIncludes = options.jsonSchema?.includeAnnotationKey;
208
208
  const undeclared = /* @__PURE__ */ new Set();
209
209
  const document = Schema.toJsonSchemaDocument(source, {
210
+ onExcessProperty: "error",
210
211
  ...options.jsonSchema,
211
212
  includeAnnotationKey: (key) => {
212
213
  if (KeywordFamilies.isDeclared(key)) return true;