@effected/schemastore 0.7.0 → 0.9.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/CatalogEntry.js CHANGED
@@ -39,6 +39,14 @@ const GENERIC_BASENAMES = /* @__PURE__ */ new Set([
39
39
  "settings"
40
40
  ]);
41
41
  const COMPLEX_GLOB = /[{}[\]]|[?*+@!]\(|^!/;
42
+ const assertDistinctVersions = (name, versions) => {
43
+ const seen = [];
44
+ for (const version of versions) {
45
+ const duplicate = seen.find((v) => SchemaVersioning.Order(v, version) === 0);
46
+ if (duplicate !== void 0) throw new Error(`CatalogEntry.assemble: "${name}" declares the same version twice, as "${duplicate}" and "${version}"`);
47
+ seen.push(version);
48
+ }
49
+ };
42
50
  const lintPattern = (pattern) => {
43
51
  const findings = [];
44
52
  const basename = pattern.slice(pattern.lastIndexOf("/") + 1);
@@ -90,9 +98,13 @@ var CatalogEntry = class CatalogEntry extends Schema.Class("CatalogEntry")({
90
98
  * Assembles an entry from a catalog identity plus
91
99
  * {@link SchemaVersioning.catalogUrls}' inputs: pass `versions` for the
92
100
  * versioned mode (the `versions` map and latest-pointing `url` are
93
- * derived), omit it for the unversioned mode.
101
+ * derived), omit it for the unversioned mode. Throws an `Error` naming
102
+ * both spellings when two labels compare equal under
103
+ * {@link SchemaVersioning.Order} (`1.2` and `1.2.0`): each would be its
104
+ * own key and URL for one document.
94
105
  */
95
106
  static assemble(options) {
107
+ if (options.versions !== void 0) assertDistinctVersions(options.name, options.versions);
96
108
  const urls = SchemaVersioning.catalogUrls({
97
109
  baseUrl: options.baseUrl,
98
110
  name: options.fileBaseName ?? options.name,
package/DriftPolicy.js ADDED
@@ -0,0 +1,30 @@
1
+ //#region src/DriftPolicy.ts
2
+ /**
3
+ * Classifies one target's change against a drift tolerance.
4
+ *
5
+ * @remarks
6
+ * Pure and synchronous. The lifecycle rule is fixed here so no caller
7
+ * relitigates it: an unpublished target is NEVER drift — it is regenerated
8
+ * in place until someone depends on its label. Gate failures (lint warnings,
9
+ * validator findings) are not drift either and are not this module's
10
+ * concern.
11
+ *
12
+ * @public
13
+ */
14
+ var DriftPolicy = class {
15
+ constructor() {}
16
+ /** `{ policy: "semantic", onDrift: "error" }` — what a config gets when it says nothing. */
17
+ static defaults = {
18
+ policy: "semantic",
19
+ onDrift: "error"
20
+ };
21
+ static classify(input, policy) {
22
+ if (!input.published || policy === "allow") return "write";
23
+ if (input.change === "contract") return "drift";
24
+ if (input.change === "annotations" && policy === "strict") return "drift";
25
+ return "write";
26
+ }
27
+ };
28
+
29
+ //#endregion
30
+ export { DriftPolicy };
package/README.md CHANGED
@@ -71,10 +71,12 @@ console.log(Effect.runSync(program));
71
71
  // "required": [
72
72
  // "name"
73
73
  // ],
74
- // "additionalProperties": false
74
+ // "additionalProperties": true
75
75
  // }
76
76
  ```
77
77
 
78
+ `additionalProperties: true` is core's default for a struct: `Schema.toJsonSchemaDocument` emits open objects unless told otherwise. A config schema usually wants the closed form, and `jsonSchema: { onExcessProperty: "error" }` in the options produces it; the same option lives on a pipeline target, below.
79
+
78
80
  `fromSchema` runs the whole pipeline — 2020-12 generation, Draft-07 lowering, the `$ref` rewrite and the declared-family gate — so every `$ref` in a built document already resolves against its `$defs` pool. `toJson()` is the flat publication shape (`$defs` omitted when empty) and `serializeResult` routes through the owned canonical serializer, tab-indented with a single trailing newline. If core cannot convert a schema, the failure is typed as `SchemaConversionError` and carries the `$id` and the structured cause.
79
81
 
80
82
  ## Annotate at the definition site
@@ -112,7 +114,7 @@ One thing the package does *not* do to a declared-family value is walk inside it
112
114
 
113
115
  ## Catalog entries and versioning
114
116
 
115
- `CatalogEntry` is the `catalog.json` entry as a `Schema.Class`, so decoding an existing entry and encoding one for submission are the same artifact. `SchemaVersion` is a **full three-component SemVer** label — `major.minor.patch` with an optional prerelease, enforced by `@effected/semver` — so ordering is plain SemVer precedence (`1.10.0` above `1.9.0`) and the label round-trips verbatim. Build metadata is rejected (`1.0.0+build.5` does not parse): SemVer precedence ignores it, so two labels differing only in build would compare equal and both claim to be the latest. Surrounding whitespace is rejected for the same round-tripping reason. The file-name convention is SchemaStore's own `<name>-<version>.json`; the label grammar is the one deliberate divergence, since the store's corpus uses partial labels like `1.2` that no SemVer parser accepts and that cannot be split back out of a file name unambiguously.
117
+ `CatalogEntry` is the `catalog.json` entry as a `Schema.Class`, so decoding an existing entry and encoding one for submission are the same artifact. `SchemaVersion` is a **one-to-three-component** label — `major`, `major.minor` or `major.minor.patch` with an optional prerelease, matched by a grammar regex and then validated by `@effected/semver` over the label padded to three components — so ordering is plain SemVer precedence (`1.10.0` above `1.9.0`; `1`, `1.0` and `1.0.0` compare equal) and the label round-trips verbatim. Build metadata is rejected (`1.0.0+build.5` does not parse): SemVer precedence ignores it, so two labels differing only in build would compare equal and both claim to be the latest. Surrounding whitespace is rejected for the same round-tripping reason. The file-name convention is SchemaStore's own `<name>-<version>.json`, and partial labels like `1.2` common in the store's corpus are accepted as written; `defineConfig` refuses two spellings of one version under one name so a file name always maps back to one label.
116
118
 
117
119
  `SchemaVersioning.isPinned(version)` answers whether a label names a published document rather than a prerelease — SemVer §9 makes a prerelease's own instability explicit, so a contract change inside one breaks nobody's pin. It is the one predicate the pipeline's contract gate and `SchemaVersioning.next` both read, so the two can never disagree about the same label. `next(current, change)` is the version a `WriteChange` classification calls for: identity for anything but a `"contract"` change on a pinned label, otherwise a MINOR bump on the 0.x line (0.x treats MINOR as the breaking axis) or MAJOR above it — always strictly greater, never a minted prerelease.
118
120
 
@@ -243,6 +245,7 @@ const targets = [
243
245
  schema: Schema.Struct({ name: Schema.String }),
244
246
  $id: "https://example.com/config.schema.json",
245
247
  path: "schemas/config.schema.json",
248
+ jsonSchema: { onExcessProperty: "error" },
246
249
  }),
247
250
  ];
248
251
 
@@ -268,6 +271,8 @@ provide the same value cannot disagree about what the layer contains.
268
271
 
269
272
  A target names its schema, its `$id` and where the file goes. `name` is optional and only catalog naming reads it, so a file-only target like the one above does not repeat its path's basename; supply it when you also pass a `version`, since versioned naming is `<name>-<version>.json`.
270
273
 
274
+ `jsonSchema` is optional too, and carries core's `Schema.ToJsonSchemaOptions` for that one target. Set it when the document's shape must not follow core's defaults: `onExcessProperty: "error"` keeps a published closed-object document closed, where the open-by-default generator would otherwise flip every struct's `additionalProperties` and the contract gate would refuse the rewrite. Living on the target rather than in the pipeline options keeps each document's generation contract self-describing.
275
+
271
276
  Both gates' findings normalize into one `PipelineFinding` shape, so a single predicate judges them. Gating is **policy, not mechanism**: `blocking` defaults to `severity === "warning"`, which is what `UnresolvedRef`, `UnknownKeyword` and `DepthExceeded` are. Replace the predicate rather than the loop when you disagree.
272
277
 
273
278
  ```ts
@@ -326,7 +331,7 @@ The classification is key-order insensitive and keyword-position aware, like the
326
331
  - `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.
327
332
  - `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.
328
333
  - `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`.
329
- - `SchemaTarget` — the target manifest vocabulary: schema, `$id`, destination path, an optional name and an optional version that requires one.
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.
330
335
  - `CanonicalJson` — the deterministic serializer with typed failures (`NonJsonValueError`, `JsonDepthExceededError`).
331
336
 
332
337
  ## License
package/SchemaPipeline.js CHANGED
@@ -195,7 +195,10 @@ var SchemaPipeline = class SchemaPipeline {
195
195
  const held = [];
196
196
  const blocked = [];
197
197
  for (const target of targets) {
198
- const document = yield* StoreDocument.fromSchema(target.schema, { $id: target.$id });
198
+ const document = yield* StoreDocument.fromSchema(target.schema, {
199
+ $id: target.$id,
200
+ ...target.jsonSchema !== void 0 ? { jsonSchema: target.jsonSchema } : {}
201
+ });
199
202
  const findings = yield* gather(document, options);
200
203
  yield* gate(target, findings, options);
201
204
  const version = target.version;
@@ -248,7 +251,10 @@ var SchemaPipeline = class SchemaPipeline {
248
251
  const files = yield* SchemaFile;
249
252
  const results = [];
250
253
  for (const target of targets) {
251
- const document = yield* StoreDocument.fromSchema(target.schema, { $id: target.$id });
254
+ const document = yield* StoreDocument.fromSchema(target.schema, {
255
+ $id: target.$id,
256
+ ...target.jsonSchema !== void 0 ? { jsonSchema: target.jsonSchema } : {}
257
+ });
252
258
  const findings = yield* gather(document, options);
253
259
  const { wouldWrite, change } = yield* files.check(target.path, document, options?.write);
254
260
  results.push({
package/SchemaTarget.js CHANGED
@@ -1,3 +1,6 @@
1
+ import { SchemaVersioning } from "./SchemaVersioning.js";
2
+ import { Result } from "effect";
3
+
1
4
  //#region src/SchemaTarget.ts
2
5
  /**
3
6
  * Constructors for `SchemaTarget` values.
@@ -10,18 +13,23 @@ var SchemaTarget = class {
10
13
  * Builds a target. `$id` and `path` must be non-empty — an empty
11
14
  * identity is a wiring mistake and throws, as does an empty `name` when
12
15
  * one is given. The `name`-with-`version` invariant is enforced by the
13
- * overloads above; the runtime check remains for untyped callers.
16
+ * overloads above; the runtime check remains for untyped callers. A
17
+ * string `version` that fails {@link SchemaVersioning.parseResult} throws,
18
+ * naming the invalid label.
14
19
  */
15
20
  static make(options) {
16
21
  for (const key of ["$id", "path"]) if (options[key].length === 0) throw new Error(`SchemaTarget.make requires a non-empty "${key}"`);
17
22
  if (options.name !== void 0 && options.name.length === 0) throw new Error("SchemaTarget.make requires a non-empty \"name\" when one is given");
18
23
  if (options.version !== void 0 && options.name === void 0) throw new Error("SchemaTarget.make requires a \"name\" when \"version\" is given (catalog naming is name-<version>.json)");
24
+ const version = options.version === void 0 ? void 0 : Result.getOrThrowWith(SchemaVersioning.parseResult(options.version), (error) => /* @__PURE__ */ new Error(`SchemaTarget.make received an invalid version label "${options.version}": ${error.message}`));
19
25
  return {
20
26
  schema: options.schema,
21
27
  $id: options.$id,
22
28
  path: options.path,
29
+ published: options.published ?? false,
23
30
  ...options.name !== void 0 ? { name: options.name } : {},
24
- ...options.version !== void 0 ? { version: options.version } : {}
31
+ ...version !== void 0 ? { version } : {},
32
+ ...options.jsonSchema !== void 0 ? { jsonSchema: options.jsonSchema } : {}
25
33
  };
26
34
  }
27
35
  };
@@ -2,9 +2,27 @@ import { Effect, Option, Order, Result, Schema } from "effect";
2
2
  import { SemVer } from "@effected/semver";
3
3
 
4
4
  //#region src/SchemaVersioning.ts
5
+ const LABEL = /^(0|[1-9]\d*)(?:\.(0|[1-9]\d*)){0,2}(-[0-9A-Za-z.-]+)?$/;
6
+ const split = (input) => {
7
+ if (!LABEL.test(input)) return;
8
+ const dash = input.indexOf("-");
9
+ const core = dash === -1 ? input : input.slice(0, dash);
10
+ const prerelease = dash === -1 ? "" : input.slice(dash);
11
+ return {
12
+ core: core.split("."),
13
+ prerelease
14
+ };
15
+ };
16
+ /** Pad a 1–3 component core to `major.minor.patch` so SemVer can order it. */
17
+ const padded = (parts) => `${[
18
+ ...parts.core,
19
+ "0",
20
+ "0"
21
+ ].slice(0, 3).join(".")}${parts.prerelease}`;
5
22
  const isVersionLabel = (input) => {
6
- if (!SemVer.isValid(input)) return false;
7
- const parsed = SemVer.parseResult(input);
23
+ const parts = split(input);
24
+ if (parts === void 0) return false;
25
+ const parsed = SemVer.parseResult(padded(parts));
8
26
  return Result.isSuccess(parsed) && parsed.success.build.length === 0;
9
27
  };
10
28
  /**
@@ -22,29 +40,34 @@ input: Schema.String }) {
22
40
  }
23
41
  };
24
42
  /**
25
- * A schema version label: a branded string holding a **full three-component
26
- * SemVer** — `major.minor.patch` with an optional prerelease, validated by
27
- * `@effected/semver` itself. Build metadata is rejected (see below).
28
- *
29
- * `1.2` and `1` are NOT accepted, though SchemaStore's own corpus uses such
30
- * labels: requiring all three components makes a label unambiguous to split
31
- * back out of `<name>-<version>.json` or its URL, which is what consumers
32
- * do with it. The file-name convention around the label stays SchemaStore's.
43
+ * A schema version label: a branded string holding one, two or three
44
+ * dot-separated numeric components — `major`, `major.minor` or
45
+ * `major.minor.patch` with an optional SemVer prerelease. Build metadata
46
+ * is rejected (see below).
33
47
  *
34
- * The label round-trips verbatim into file names and catalog `versions`
35
- * keys; ordering parses it directly (see {@link SchemaVersioning.Order}).
48
+ * This matches SchemaStore's own corpus, which commonly uses partial labels
49
+ * like `agripparc-1.2.json`. The label is preserved VERBATIM — it is the
50
+ * file name and the URL — and a missing component is read as zero only for
51
+ * ordering (see {@link SchemaVersioning.Order}), never rewritten into the
52
+ * label itself.
36
53
  *
37
54
  * @public
38
55
  */
39
- const SchemaVersion = Schema.String.check(Schema.makeFilter((value) => isVersionLabel(value) ? void 0 : "must be a full major.minor.patch SemVer label")).pipe(Schema.brand("SchemaVersion"));
56
+ const SchemaVersion = Schema.String.check(Schema.makeFilter((value) => isVersionLabel(value) ? void 0 : "must be a major, major.minor or major.minor.patch version label")).pipe(Schema.brand("SchemaVersion"));
40
57
  const orderingKey = (label) => {
41
- const result = SemVer.parseResult(label);
42
- if (Result.isFailure(result)) throw new Error(`SchemaVersion ordering invariant violated for label "${label}"`);
58
+ const parts = split(label);
59
+ const result = parts === void 0 ? void 0 : SemVer.parseResult(padded(parts));
60
+ if (result === void 0 || Result.isFailure(result)) throw new Error(`SchemaVersion ordering invariant violated for label "${label}"`);
43
61
  return result.success;
44
62
  };
45
- const bumpBreaking = (current, parsed) => {
63
+ const bumpNext = (current, parsed, components) => {
46
64
  try {
47
- return parsed.major === 0 ? parsed.bump.minor() : parsed.bump.major();
65
+ const bumped = components === 1 ? parsed.bump.major() : parsed.bump.minor();
66
+ return [
67
+ bumped.major,
68
+ bumped.minor,
69
+ bumped.patch
70
+ ].slice(0, components).join(".");
48
71
  } catch (cause) {
49
72
  throw new Error(`SchemaVersion bump invariant violated: "${current}" cannot be bumped past Number.MAX_SAFE_INTEGER (${Number.MAX_SAFE_INTEGER})`, { cause });
50
73
  }
@@ -63,8 +86,10 @@ const joinUrl = (baseUrl, file) => {
63
86
  * — SchemaStore's own suffix convention — a `versions` map, and `url`
64
87
  * pointing at the latest version).
65
88
  *
66
- * Version labels are full three-component SemVer, so ordering is plain
67
- * SemVer precedence: `1.10.0` above `1.9.0`, `2.0.0-beta` below `2.0.0`.
89
+ * Version labels are 1–3 dot-separated numeric components with an optional
90
+ * prerelease; ordering reads a missing component as zero and otherwise
91
+ * follows plain SemVer precedence: `1.10` above `1.9`, `2.0.0-beta` below
92
+ * `2.0.0`.
68
93
  *
69
94
  * @public
70
95
  */
@@ -112,6 +137,15 @@ var SchemaVersioning = class SchemaVersioning {
112
137
  return orderingKey(version).prerelease.length === 0;
113
138
  }
114
139
  /**
140
+ * The number of dot-separated numeric components in a label's core:
141
+ * `1` for `"1"` (or `"1-beta"`), `2` for `"1.2"`, `3` for `"1.2.3"`.
142
+ */
143
+ static components(version) {
144
+ const parts = split(version);
145
+ if (parts === void 0) throw new Error(`SchemaVersion components invariant violated for label "${version}"`);
146
+ return parts.core.length;
147
+ }
148
+ /**
115
149
  * The version label a change classification calls for. Pure and
116
150
  * synchronous; total over validated labels — a non-label input is a wiring
117
151
  * bug and dies as a defect, the same as {@link SchemaVersioning.Order}.
@@ -123,13 +157,15 @@ var SchemaVersioning = class SchemaVersioning {
123
157
  * declares its own instability; the pipeline's `"block-versioned"`
124
158
  * policy uses the same {@link SchemaVersioning.isPinned}, so the gate
125
159
  * and the bump agree.
126
- * - `major === 0` MINOR bump (`0.4.0` → `0.5.0`): on the 0.x line MINOR
127
- * is the breaking axis.
128
- * - otherwise → MAJOR bump (`5.0.0` → `6.0.0`).
160
+ * - otherwise a MINOR bump, preserving the component count
161
+ * ({@link SchemaVersioning.components}): `1.2.3` → `1.3.0`, `1.2` →
162
+ * `1.3`, `0.4` → `0.5`. On a one-component label the only axis IS
163
+ * major: `1` → `2`.
129
164
  *
130
165
  * The bump's job is to be strictly greater and conspicuous, NOT to encode
131
166
  * SemVer compatibility: `DocumentDiff` cannot tell an added optional
132
167
  * property from a removed required one, so every contract change reads as
168
+ * breaking — the author bumps MAJOR by hand when they know the change is
133
169
  * breaking. Each label is its own file and URL, so an over-bump costs a
134
170
  * file; an under-bump would overwrite a pinned document. `next` never
135
171
  * introduces a prerelease from a stable input.
@@ -140,7 +176,7 @@ var SchemaVersioning = class SchemaVersioning {
140
176
  */
141
177
  static next(current, change) {
142
178
  if (change !== "contract" || !SchemaVersioning.isPinned(current)) return current;
143
- const label = bumpBreaking(current, orderingKey(current)).toString();
179
+ const label = bumpNext(current, orderingKey(current), SchemaVersioning.components(current));
144
180
  const reparsed = SchemaVersioning.parseResult(label);
145
181
  if (Result.isFailure(reparsed)) throw new Error(`SchemaVersion bump invariant violated: "${current}" bumped to "${label}"`);
146
182
  return reparsed.success;
@@ -173,13 +209,14 @@ var SchemaVersioning = class SchemaVersioning {
173
209
  * a contradiction (versioned mode with no versions) and throws — pass
174
210
  * `undefined` for the unversioned mode.
175
211
  *
176
- * Labels are inserted in ascending {@link SchemaVersioning.Order} and
177
- * stay that way on serialization. Requiring three components is what
178
- * buys this: JavaScript enumerates array-index-like keys first, so the
179
- * old grammar's bare-major label (`"2"`) jumped ahead of every dotted
180
- * one regardless of insertion order. No SemVer label is integer-like,
181
- * so that hazard is gone. Deriving ordering from the labels themselves
182
- * (as {@link SchemaVersioning.latest} does) is still the robust read.
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.
183
220
  */
184
221
  static catalogUrls(options) {
185
222
  const { baseUrl, name, versions } = options;
@@ -0,0 +1,118 @@
1
+ import { SchemaVersioning } from "./SchemaVersioning.js";
2
+ import { CatalogEntry } from "./CatalogEntry.js";
3
+ import { DriftPolicy } from "./DriftPolicy.js";
4
+ import { Schema } from "effect";
5
+
6
+ //#region src/SchemastoreConfig.ts
7
+ 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;
27
+ };
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);
38
+ }
39
+ return map;
40
+ };
41
+ const normalizePath = (raw) => {
42
+ const absolute = raw.startsWith("/");
43
+ const out = [];
44
+ for (const segment of raw.split("/")) {
45
+ if (segment === "" || segment === ".") continue;
46
+ if (segment === "..") {
47
+ if (out.length > 0 && out[out.length - 1] !== "..") out.pop();
48
+ else if (!absolute) out.push(segment);
49
+ continue;
50
+ }
51
+ out.push(segment);
52
+ }
53
+ return `${absolute ? "/" : ""}${out.join("/")}`;
54
+ };
55
+ const assertUniquePaths = (schemas, catalog) => {
56
+ const seen = /* @__PURE__ */ new Set();
57
+ for (const p of [...schemas.map((target) => target.path), ...catalog.map((entry) => entry.path)]) {
58
+ const normalized = normalizePath(p);
59
+ if (seen.has(normalized)) throw new Error(`defineConfig: output path "${p}" is declared twice`);
60
+ seen.add(normalized);
61
+ }
62
+ };
63
+ /**
64
+ * Validate and assemble a `schemastore.config.ts` value.
65
+ *
66
+ * @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.
76
+ *
77
+ * @public
78
+ */
79
+ 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));
99
+ return {
100
+ [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
+ }
107
+ };
108
+ };
109
+ /**
110
+ * Whether a value is a config produced by {@link defineConfig} — the check a
111
+ * loader runs on a config module's default export.
112
+ *
113
+ * @public
114
+ */
115
+ const isSchemastoreConfig = (value) => typeof value === "object" && value !== null && value[ConfigBrand] === true;
116
+
117
+ //#endregion
118
+ export { defineConfig, isSchemastoreConfig };
package/index.d.ts CHANGED
@@ -577,17 +577,16 @@ export declare class InvalidSchemaVersionError extends InvalidSchemaVersionError
577
577
  get message(): string;
578
578
  }
579
579
  /**
580
- * A schema version label: a branded string holding a **full three-component
581
- * SemVer** — `major.minor.patch` with an optional prerelease, validated by
582
- * `@effected/semver` itself. Build metadata is rejected (see below).
580
+ * A schema version label: a branded string holding one, two or three
581
+ * dot-separated numeric components — `major`, `major.minor` or
582
+ * `major.minor.patch` with an optional SemVer prerelease. Build metadata
583
+ * is rejected (see below).
583
584
  *
584
- * `1.2` and `1` are NOT accepted, though SchemaStore's own corpus uses such
585
- * labels: requiring all three components makes a label unambiguous to split
586
- * back out of `<name>-<version>.json` or its URL, which is what consumers
587
- * do with it. The file-name convention around the label stays SchemaStore's.
588
- *
589
- * The label round-trips verbatim into file names and catalog `versions`
590
- * keys; ordering parses it directly (see {@link SchemaVersioning.Order}).
585
+ * This matches SchemaStore's own corpus, which commonly uses partial labels
586
+ * like `agripparc-1.2.json`. The label is preserved VERBATIM it is the
587
+ * file name and the URL and a missing component is read as zero only for
588
+ * ordering (see {@link SchemaVersioning.Order}), never rewritten into the
589
+ * label itself.
591
590
  *
592
591
  * @public
593
592
  */
@@ -608,9 +607,14 @@ interface CatalogUrls {
608
607
  /** The catalog `url` — the unversioned file, or the latest versioned file. */
609
608
  readonly url: string;
610
609
  /**
611
- * The versioned catalog's `versions` map (label → url), inserted — and,
612
- * since a three-component label can never be integer-like, enumerated
613
- * and serialized — in ascending version order.
610
+ * The versioned catalog's `versions` map (label → url), inserted in
611
+ * ascending version order.
612
+ *
613
+ * A bare-major label (`"2"`) is array-index-like, so JavaScript
614
+ * enumerates it FIRST regardless of insertion order — the serialized
615
+ * order of such a key is therefore not meaningful. A two- or
616
+ * three-component label is never integer-like and keeps insertion order
617
+ * through serialization.
614
618
  */
615
619
  readonly versions?: Readonly<Record<string, string>>;
616
620
  }
@@ -620,8 +624,10 @@ interface CatalogUrls {
620
624
  * — SchemaStore's own suffix convention — a `versions` map, and `url`
621
625
  * pointing at the latest version).
622
626
  *
623
- * Version labels are full three-component SemVer, so ordering is plain
624
- * SemVer precedence: `1.10.0` above `1.9.0`, `2.0.0-beta` below `2.0.0`.
627
+ * Version labels are 1–3 dot-separated numeric components with an optional
628
+ * prerelease; ordering reads a missing component as zero and otherwise
629
+ * follows plain SemVer precedence: `1.10` above `1.9`, `2.0.0-beta` below
630
+ * `2.0.0`.
625
631
  *
626
632
  * @public
627
633
  */
@@ -662,6 +668,11 @@ export declare class SchemaVersioning {
662
668
  * deadlock.
663
669
  */
664
670
  static isPinned(version: SchemaVersion): boolean;
671
+ /**
672
+ * The number of dot-separated numeric components in a label's core:
673
+ * `1` for `"1"` (or `"1-beta"`), `2` for `"1.2"`, `3` for `"1.2.3"`.
674
+ */
675
+ static components(version: SchemaVersion): 1 | 2 | 3;
665
676
  /**
666
677
  * The version label a change classification calls for. Pure and
667
678
  * synchronous; total over validated labels — a non-label input is a wiring
@@ -674,13 +685,15 @@ export declare class SchemaVersioning {
674
685
  * declares its own instability; the pipeline's `"block-versioned"`
675
686
  * policy uses the same {@link SchemaVersioning.isPinned}, so the gate
676
687
  * and the bump agree.
677
- * - `major === 0` MINOR bump (`0.4.0` → `0.5.0`): on the 0.x line MINOR
678
- * is the breaking axis.
679
- * - otherwise → MAJOR bump (`5.0.0` → `6.0.0`).
688
+ * - otherwise a MINOR bump, preserving the component count
689
+ * ({@link SchemaVersioning.components}): `1.2.3` → `1.3.0`, `1.2` →
690
+ * `1.3`, `0.4` → `0.5`. On a one-component label the only axis IS
691
+ * major: `1` → `2`.
680
692
  *
681
693
  * The bump's job is to be strictly greater and conspicuous, NOT to encode
682
694
  * SemVer compatibility: `DocumentDiff` cannot tell an added optional
683
695
  * property from a removed required one, so every contract change reads as
696
+ * breaking — the author bumps MAJOR by hand when they know the change is
684
697
  * breaking. Each label is its own file and URL, so an over-bump costs a
685
698
  * file; an under-bump would overwrite a pinned document. `next` never
686
699
  * introduces a prerelease from a stable input.
@@ -713,13 +726,14 @@ export declare class SchemaVersioning {
713
726
  * a contradiction (versioned mode with no versions) and throws — pass
714
727
  * `undefined` for the unversioned mode.
715
728
  *
716
- * Labels are inserted in ascending {@link SchemaVersioning.Order} and
717
- * stay that way on serialization. Requiring three components is what
718
- * buys this: JavaScript enumerates array-index-like keys first, so the
719
- * old grammar's bare-major label (`"2"`) jumped ahead of every dotted
720
- * one regardless of insertion order. No SemVer label is integer-like,
721
- * so that hazard is gone. Deriving ordering from the labels themselves
722
- * (as {@link SchemaVersioning.latest} does) is still the robust read.
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.
723
737
  */
724
738
  static catalogUrls(options: {
725
739
  readonly baseUrl: string;
@@ -774,7 +788,10 @@ export declare class CatalogEntry extends CatalogEntry_base {
774
788
  * Assembles an entry from a catalog identity plus
775
789
  * {@link SchemaVersioning.catalogUrls}' inputs: pass `versions` for the
776
790
  * versioned mode (the `versions` map and latest-pointing `url` are
777
- * derived), omit it for the unversioned mode.
791
+ * derived), omit it for the unversioned mode. Throws an `Error` naming
792
+ * both spellings when two labels compare equal under
793
+ * {@link SchemaVersioning.Order} (`1.2` and `1.2.0`): each would be its
794
+ * own key and URL for one document.
778
795
  */
779
796
  static assemble(options: {
780
797
  readonly name: string;
@@ -844,6 +861,64 @@ export declare class DocumentLint {
844
861
  static lint(document: StoreDocument): ReadonlyArray<DocumentLintFinding>;
845
862
  }
846
863
  //#endregion
864
+ //#region src/DriftPolicy.d.ts
865
+ /**
866
+ * How much change a PUBLISHED schema document may absorb before a build is
867
+ * refused.
868
+ *
869
+ * @remarks
870
+ * - `"strict"` — any content change (`annotations` or `contract`) is drift.
871
+ * - `"semantic"` — only a `contract` change is drift; documentation-only
872
+ * keywords rewrite in place.
873
+ * - `"allow"` — nothing is drift; the document rewrites in place.
874
+ *
875
+ * @public
876
+ */
877
+ type DriftTolerance = "strict" | "semantic" | "allow";
878
+ /**
879
+ * What a build does when it finds drift: refuse to write anything, or write
880
+ * and warn.
881
+ *
882
+ * @public
883
+ */
884
+ type OnDrift = "error" | "warn";
885
+ /**
886
+ * The drift settings a config declares and a CLI flag may override.
887
+ *
888
+ * @public
889
+ */
890
+ interface DriftOptions {
891
+ readonly policy: DriftTolerance;
892
+ readonly onDrift: OnDrift;
893
+ }
894
+ /**
895
+ * The verdict for one target: write it, or hold it as drift.
896
+ *
897
+ * @public
898
+ */
899
+ type DriftVerdict = "write" | "drift";
900
+ /**
901
+ * Classifies one target's change against a drift tolerance.
902
+ *
903
+ * @remarks
904
+ * Pure and synchronous. The lifecycle rule is fixed here so no caller
905
+ * relitigates it: an unpublished target is NEVER drift — it is regenerated
906
+ * in place until someone depends on its label. Gate failures (lint warnings,
907
+ * validator findings) are not drift either and are not this module's
908
+ * concern.
909
+ *
910
+ * @public
911
+ */
912
+ export declare class DriftPolicy {
913
+ private constructor();
914
+ /** `{ policy: "semantic", onDrift: "error" }` — what a config gets when it says nothing. */
915
+ static readonly defaults: DriftOptions;
916
+ static classify(input: {
917
+ readonly published: boolean;
918
+ readonly change: WriteChange;
919
+ }, policy: DriftTolerance): DriftVerdict;
920
+ }
921
+ //#endregion
847
922
  //#region src/KeywordFamilies.d.ts
848
923
  /**
849
924
  * The one owner of the declared non-standard keyword families, in two
@@ -969,6 +1044,30 @@ export interface SchemaTarget {
969
1044
  * rewritten in place.
970
1045
  */
971
1046
  readonly version?: SchemaVersion;
1047
+ /**
1048
+ * Whether a consumer already depends on this document at this label.
1049
+ *
1050
+ * @remarks
1051
+ * The lifecycle switch the drift policy reads: an unpublished target is
1052
+ * always regenerated in place, a published one is held to the configured
1053
+ * drift tolerance. Defaults to `false`. The library's own
1054
+ * `contractChanges: "block-versioned"` policy keys on a pinned `version`,
1055
+ * not on this flag — the CLI is what reads it.
1056
+ */
1057
+ readonly published: boolean;
1058
+ /**
1059
+ * Options passed through to {@link StoreDocument.fromSchema} (and, from
1060
+ * there, core's `Schema.toJsonSchemaDocument`).
1061
+ *
1062
+ * A target-level field rather than a pipeline-wide default keeps each
1063
+ * document's generation contract self-describing, so
1064
+ * {@link SchemaPipeline} reproduces a document deterministically
1065
+ * regardless of core's own default — for example, an `onExcessProperty`
1066
+ * setting of `error` restores closed objects after rc.113 flipped that
1067
+ * default open. See {@link StoreDocumentOptions.jsonSchema} for the
1068
+ * `includeAnnotationKey` gate this option is also subject to.
1069
+ */
1070
+ readonly jsonSchema?: Schema.ToJsonSchemaOptions;
972
1071
  }
973
1072
  /**
974
1073
  * Constructors for `SchemaTarget` values.
@@ -986,19 +1085,24 @@ export declare class SchemaTarget {
986
1085
  readonly $id: string;
987
1086
  readonly name?: string;
988
1087
  readonly path: string;
1088
+ readonly published?: boolean;
1089
+ readonly jsonSchema?: Schema.ToJsonSchemaOptions;
989
1090
  }): SchemaTarget;
990
1091
  /**
991
1092
  * Builds a versioned target. `name` is **required** here: versioned
992
1093
  * catalog naming is `name-<version>.json`, so a version without a name
993
1094
  * cannot be resolved — the overload pair makes that unrepresentable
994
- * rather than a runtime throw.
1095
+ * rather than a runtime throw. `version` also accepts a plain string
1096
+ * label, parsed via {@link SchemaVersioning.parseResult}.
995
1097
  */
996
1098
  static make(options: {
997
1099
  readonly schema: Schema.Constraint;
998
1100
  readonly $id: string;
999
1101
  readonly name: string;
1000
1102
  readonly path: string;
1001
- readonly version: SchemaVersion;
1103
+ readonly version: SchemaVersion | string;
1104
+ readonly published?: boolean;
1105
+ readonly jsonSchema?: Schema.ToJsonSchemaOptions;
1002
1106
  }): SchemaTarget;
1003
1107
  }
1004
1108
  //#endregion
@@ -1460,5 +1564,81 @@ export declare class SchemaPipeline {
1460
1564
  static checkOne(target: SchemaTarget, options?: SchemaPipelineOptions): Effect.Effect<PipelineCheckResult, SchemaConversionError | UndeclaredAnnotationKeyError | SchemaValidatorError | CanonicalJsonError | SchemaFileReadError, SchemaFile | SchemaValidator>;
1461
1565
  }
1462
1566
  //#endregion
1463
- export type { CanonicalJsonError, CanonicalJsonOptions, CatalogUrls, CheckResult, ContractChangePolicy, PipelineCheckResult, PipelineResult, SchemaChange, SchemaFileShape, SchemaPipelineOptions, SchemaValidatorOptions, SchemaValidatorShape, SchemaWriteOptions, StoreDocumentOptions, WriteChange, WriteOutcome, WriteResult };
1567
+ //#region src/SchemastoreConfig.d.ts
1568
+ declare const ConfigBrand: unique symbol;
1569
+ /**
1570
+ * One catalog entry a `schemastore.config.ts` declares: the SchemaStore
1571
+ * `catalog.json` fields plus where to write the assembled entry. The entry's
1572
+ * `versions` and `url` are derived by {@link defineConfig} from every
1573
+ * versioned schema of the same `name`.
1574
+ *
1575
+ * @public
1576
+ */
1577
+ interface CatalogConfig {
1578
+ readonly name: string;
1579
+ readonly description: string;
1580
+ readonly fileMatch: ReadonlyArray<string>;
1581
+ readonly baseUrl: string;
1582
+ /** Where to write the assembled entry; relative paths are resolved by the loader against the config file's directory. */
1583
+ readonly path: string;
1584
+ }
1585
+ /**
1586
+ * What a `schemastore.config.ts` hands to {@link defineConfig}: the schema
1587
+ * targets, an optional catalog block and an optional partial drift block.
1588
+ *
1589
+ * @public
1590
+ */
1591
+ interface SchemastoreConfigInput {
1592
+ readonly schemas: ReadonlyArray<SchemaTarget>;
1593
+ readonly catalog?: ReadonlyArray<CatalogConfig>;
1594
+ readonly drift?: Partial<DriftOptions>;
1595
+ }
1596
+ /**
1597
+ * A validated catalog declaration paired with the `CatalogEntry` assembled
1598
+ * from it.
1599
+ *
1600
+ * @public
1601
+ */
1602
+ interface CatalogTarget {
1603
+ readonly config: CatalogConfig;
1604
+ readonly entry: CatalogEntry;
1605
+ }
1606
+ /**
1607
+ * The validated, defaults-filled config {@link defineConfig} answers and the
1608
+ * CLI consumes. Recognisable via {@link isSchemastoreConfig}.
1609
+ *
1610
+ * @public
1611
+ */
1612
+ interface SchemastoreConfig {
1613
+ readonly [ConfigBrand]: true;
1614
+ readonly schemas: ReadonlyArray<SchemaTarget>;
1615
+ readonly catalog: ReadonlyArray<CatalogTarget>;
1616
+ readonly drift: DriftOptions;
1617
+ }
1618
+ /**
1619
+ * Validate and assemble a `schemastore.config.ts` value.
1620
+ *
1621
+ * @remarks
1622
+ * Pure: no IO, no Effect. Identity-with-validation over the input, filling
1623
+ * drift defaults, deriving each catalog entry's `versions` from EVERY
1624
+ * versioned schema of that name (published or not — the entry is what gets
1625
+ * submitted to become published), and branding the result so a loader can
1626
+ * recognise a config module's default export. Throws a plain `Error` on a
1627
+ * bad input; the CLI wraps it into its typed config-load error. Rejects an
1628
+ * output `path` declared twice across schemas and catalog entries, compared
1629
+ * after a lexical normalisation (`./`, `..`, trailing `/`); the CLI's loader
1630
+ * re-checks on the resolved absolute paths.
1631
+ *
1632
+ * @public
1633
+ */
1634
+ export declare const defineConfig: (input: SchemastoreConfigInput) => SchemastoreConfig;
1635
+ /**
1636
+ * Whether a value is a config produced by {@link defineConfig} — the check a
1637
+ * loader runs on a config module's default export.
1638
+ *
1639
+ * @public
1640
+ */
1641
+ export declare const isSchemastoreConfig: (value: unknown) => value is SchemastoreConfig;
1642
+ //#endregion
1643
+ export type { CanonicalJsonError, CanonicalJsonOptions, CatalogConfig, CatalogTarget, CatalogUrls, CheckResult, ContractChangePolicy, DriftOptions, DriftTolerance, DriftVerdict, OnDrift, PipelineCheckResult, PipelineResult, SchemaChange, SchemaFileShape, SchemaPipelineOptions, SchemaValidatorOptions, SchemaValidatorShape, SchemaWriteOptions, SchemastoreConfig, SchemastoreConfigInput, StoreDocumentOptions, WriteChange, WriteOutcome, WriteResult };
1464
1644
  //# sourceMappingURL=index.d.ts.map
package/index.js CHANGED
@@ -4,10 +4,12 @@ import { CatalogEntry, CatalogLintFinding } from "./CatalogEntry.js";
4
4
  import { KeywordFamilies } from "./KeywordFamilies.js";
5
5
  import { DocumentDiff } from "./DocumentDiff.js";
6
6
  import { DocumentLint, DocumentLintFinding } from "./DocumentLint.js";
7
+ import { DriftPolicy } from "./DriftPolicy.js";
7
8
  import { SchemaFile, SchemaFileNotFoundError, SchemaFileReadError, SchemaFileWriteError } from "./SchemaFile.js";
8
9
  import { SchemaValidator, SchemaValidatorError, ValidationFinding } from "./SchemaValidator.js";
9
10
  import { DRAFT_07_META_SCHEMA, SchemaConversionError, StoreDocument, UndeclaredAnnotationKeyError } from "./StoreDocument.js";
10
11
  import { ContractChangeTarget, PipelineFinding, SchemaContractChangeError, SchemaGateError, SchemaPipeline } from "./SchemaPipeline.js";
12
+ import { defineConfig, isSchemastoreConfig } from "./SchemastoreConfig.js";
11
13
  import { SchemaTarget } from "./SchemaTarget.js";
12
14
 
13
- export { CanonicalJson, CatalogEntry, CatalogLintFinding, ContractChangeTarget, DRAFT_07_META_SCHEMA, DocumentDiff, DocumentLint, DocumentLintFinding, InvalidSchemaVersionError, JsonDepthExceededError, KeywordFamilies, NonJsonValueError, PipelineFinding, SchemaContractChangeError, SchemaConversionError, SchemaFile, SchemaFileNotFoundError, SchemaFileReadError, SchemaFileWriteError, SchemaGateError, SchemaPipeline, SchemaTarget, SchemaValidator, SchemaValidatorError, SchemaVersion, SchemaVersioning, StoreDocument, UndeclaredAnnotationKeyError, ValidationFinding };
15
+ export { CanonicalJson, CatalogEntry, CatalogLintFinding, ContractChangeTarget, DRAFT_07_META_SCHEMA, DocumentDiff, DocumentLint, DocumentLintFinding, DriftPolicy, InvalidSchemaVersionError, JsonDepthExceededError, KeywordFamilies, NonJsonValueError, PipelineFinding, SchemaContractChangeError, SchemaConversionError, SchemaFile, SchemaFileNotFoundError, SchemaFileReadError, SchemaFileWriteError, SchemaGateError, SchemaPipeline, SchemaTarget, SchemaValidator, SchemaValidatorError, SchemaVersion, SchemaVersioning, StoreDocument, UndeclaredAnnotationKeyError, ValidationFinding, defineConfig, isSchemastoreConfig };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@effected/schemastore",
3
- "version": "0.7.0",
3
+ "version": "0.9.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": [