@effected/schemastore 0.8.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
@@ -114,7 +114,7 @@ One thing the package does *not* do to a declared-family value is walk inside it
114
114
 
115
115
  ## Catalog entries and versioning
116
116
 
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 **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.
118
118
 
119
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.
120
120
 
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,22 @@ 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 } : {},
25
32
  ...options.jsonSchema !== void 0 ? { jsonSchema: options.jsonSchema } : {}
26
33
  };
27
34
  }
@@ -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,17 @@ 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;
972
1058
  /**
973
1059
  * Options passed through to {@link StoreDocument.fromSchema} (and, from
974
1060
  * there, core's `Schema.toJsonSchemaDocument`).
@@ -999,20 +1085,23 @@ export declare class SchemaTarget {
999
1085
  readonly $id: string;
1000
1086
  readonly name?: string;
1001
1087
  readonly path: string;
1088
+ readonly published?: boolean;
1002
1089
  readonly jsonSchema?: Schema.ToJsonSchemaOptions;
1003
1090
  }): SchemaTarget;
1004
1091
  /**
1005
1092
  * Builds a versioned target. `name` is **required** here: versioned
1006
1093
  * catalog naming is `name-<version>.json`, so a version without a name
1007
1094
  * cannot be resolved — the overload pair makes that unrepresentable
1008
- * rather than a runtime throw.
1095
+ * rather than a runtime throw. `version` also accepts a plain string
1096
+ * label, parsed via {@link SchemaVersioning.parseResult}.
1009
1097
  */
1010
1098
  static make(options: {
1011
1099
  readonly schema: Schema.Constraint;
1012
1100
  readonly $id: string;
1013
1101
  readonly name: string;
1014
1102
  readonly path: string;
1015
- readonly version: SchemaVersion;
1103
+ readonly version: SchemaVersion | string;
1104
+ readonly published?: boolean;
1016
1105
  readonly jsonSchema?: Schema.ToJsonSchemaOptions;
1017
1106
  }): SchemaTarget;
1018
1107
  }
@@ -1475,5 +1564,81 @@ export declare class SchemaPipeline {
1475
1564
  static checkOne(target: SchemaTarget, options?: SchemaPipelineOptions): Effect.Effect<PipelineCheckResult, SchemaConversionError | UndeclaredAnnotationKeyError | SchemaValidatorError | CanonicalJsonError | SchemaFileReadError, SchemaFile | SchemaValidator>;
1476
1565
  }
1477
1566
  //#endregion
1478
- 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 };
1479
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.8.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": [