@effected/schemastore 0.4.0 → 0.6.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/DocumentLint.js CHANGED
@@ -107,7 +107,7 @@ const lintSchema = (node, path, depth, context) => {
107
107
  check: "UnknownKeyword",
108
108
  severity: "warning",
109
109
  path: keyPath,
110
- message: `"${key}" is not a Draft-07 keyword or a declared non-standard family (x-taplo*, x-tombi-*, x-intellij-*, vscode); ajv strict mode rejects it`
110
+ message: `"${key}" is not a Draft-07 keyword or a declared non-standard family (x-taplo*, x-tombi-*, x-intellij-*, x-ai-*, vscode); ajv strict mode rejects it`
111
111
  }));
112
112
  continue;
113
113
  }
@@ -160,7 +160,7 @@ const lintSchema = (node, path, depth, context) => {
160
160
  * `#/definitions/...` pointer, is a warning).
161
161
  * - `UnknownKeyword` — no keyword outside Draft-07 plus the declared
162
162
  * non-standard families ({@link KeywordFamilies}: `x-taplo*`, `x-tombi-*`,
163
- * `x-intellij-*` and the vscode set), which ajv strict mode would reject.
163
+ * `x-intellij-*`, `x-ai-*` and the vscode set), which ajv strict mode would reject.
164
164
  * - `DescriptionWithoutUrl` — advisory: SchemaStore's description
165
165
  * convention ends the root description with a docs URL line.
166
166
  *
@@ -1,9 +1,11 @@
1
1
  //#region src/KeywordFamilies.ts
2
2
  /**
3
- * The one owner of the declared non-standard keyword families the
4
- * language-server keyword sets SchemaStore's CONTRIBUTING enumerates as
5
- * legitimately consumed by editor toolchains, which ajv strict mode would
6
- * otherwise reject:
3
+ * The one owner of the declared non-standard keyword families, in two
4
+ * groups.
5
+ *
6
+ * **Upstream language-server families** — mirrored from SchemaStore's
7
+ * CONTRIBUTING, the keyword sets legitimately consumed by editor
8
+ * toolchains that ajv strict mode would otherwise reject:
7
9
  *
8
10
  * - **vscode-json-languageservice** (exact names): `allowTrailingCommas`,
9
11
  * `defaultSnippets`, `enumDescriptions`, `markdownDescription`,
@@ -16,10 +18,48 @@
16
18
  * - **IntelliJ**: the `x-intellij-` prefix (`x-intellij-language-injection`,
17
19
  * `x-intellij-html-description`, `x-intellij-enum-metadata`).
18
20
  *
21
+ * **The house machine-annotation family** — `x-ai-` (WITH the trailing
22
+ * dash; bare `x-ai` and a look-alike prefix like `x-aida-foo` are NOT
23
+ * declared), owned by this package rather than mirrored from anywhere:
24
+ *
25
+ * - It is a NAMESPACE, not a vocabulary — any `x-ai-*` key is declared, and
26
+ * this package does not enumerate specific keys.
27
+ * - The key itself must be one ajv can register: after the prefix, only
28
+ * `[A-Za-z0-9_$:-]` (ajv holds a keyword name to
29
+ * `/^[a-z_$][a-z0-9_$:-]*$/i`). A dot, a space, a slash, an `@`, a `+` or
30
+ * any non-ASCII character makes the engine gate reject the whole document
31
+ * — as a root-pathed `ValidationFinding`, not an error.
32
+ * - A value under a declared `x-ai-*` key must be JSON — `CanonicalJson`
33
+ * fails typed (`NonJsonValueError`) on anything else, the same as
34
+ * every other emitted value.
35
+ * - The one recommended, non-binding key is `x-ai-hint`: a string carrying
36
+ * an instruction to a machine reader about the annotated value.
37
+ * - `x-ai-example` is deliberately NOT recommended. Draft-07's own
38
+ * `examples` keyword already exists, is carried by the assembly, and
39
+ * classifies as a CONTRACT change in `DocumentDiff`; an `x-ai-*`
40
+ * key classifies as ANNOTATIONS. The two would be two example channels
41
+ * with opposite version semantics.
42
+ * - A declared-family value must not contain an `$id` — or a repeated
43
+ * `$anchor` — at ANY depth, not merely as its own top-level key: ajv's
44
+ * reference collection walks unknown keywords looking for them, so a
45
+ * colliding one buried anywhere inside an annotation payload fails the
46
+ * compile, surfacing as a blocking root-pathed `ValidationFinding` rather
47
+ * than a silent no-op. An empty-string `$id` collides too — it resolves
48
+ * to the root id.
49
+ * - No upstream tool sanctions `x-ai-`: a document carrying it that is
50
+ * submitted to schemastore.org needs the corresponding entry added to
51
+ * that repo's own validation config. Until then it is intended for
52
+ * self-hosted publication.
53
+ * - `DocumentDiff` classifies a delta confined to `x-ai-*` keys as
54
+ * `"annotations"`, so adopting the family on an already-published
55
+ * versioned document rewrites that file in place — correct, because
56
+ * annotations are transparently replaceable.
57
+ *
19
58
  * Both consumers of the registry route through {@link KeywordFamilies.isDeclared}:
20
59
  * `DocumentLint`'s `UnknownKeyword` check (a declared key is not flagged) and
21
- * `AnnotationCarriers` (only declared keys are re-grafted after the Draft-07
22
- * lowering). One predicate, so the lint and the carriers cannot drift.
60
+ * `StoreDocument.fromSchema`'s gate (a key outside the families fails the
61
+ * build rather than being emitted). One predicate, so the lint and the gate
62
+ * cannot drift.
23
63
  */
24
64
  const VSCODE_KEYWORDS = /* @__PURE__ */ new Set([
25
65
  "allowTrailingCommas",
@@ -31,7 +71,7 @@ const VSCODE_KEYWORDS = /* @__PURE__ */ new Set([
31
71
  /**
32
72
  * The declared non-standard keyword families as one predicate: the
33
73
  * vscode-json-languageservice set by exact name, plus the `x-taplo`,
34
- * `x-tombi-` and `x-intellij-` prefixes.
74
+ * `x-tombi-`, `x-intellij-` and `x-ai-` prefixes.
35
75
  *
36
76
  * @public
37
77
  */
@@ -40,10 +80,11 @@ var KeywordFamilies = class {
40
80
  /**
41
81
  * Whether `key` belongs to a declared non-standard keyword family.
42
82
  * Draft-07's own keywords are a separate vocabulary — this predicate
43
- * answers only for the language-server extension families.
83
+ * answers only for the language-server extension families and the
84
+ * house `x-ai-` machine-annotation namespace.
44
85
  */
45
86
  static isDeclared(key) {
46
- return VSCODE_KEYWORDS.has(key) || key.startsWith("x-taplo") || key.startsWith("x-tombi-") || key.startsWith("x-intellij-");
87
+ return VSCODE_KEYWORDS.has(key) || key.startsWith("x-taplo") || key.startsWith("x-tombi-") || key.startsWith("x-intellij-") || key.startsWith("x-ai-");
47
88
  }
48
89
  };
49
90
 
package/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  [![Node.js %3E%3D24.11.0](https://img.shields.io/badge/Node.js-%3E%3D24.11.0-5fa04e.svg)](https://nodejs.org/)
6
6
  [![TypeScript 7.0](https://img.shields.io/badge/TypeScript-7.0-3178c6.svg)](https://www.typescriptlang.org/)
7
7
 
8
- Build, version, validate and lint SchemaStore-shaped Draft-07 JSON Schema documents from Effect Schema sources. Core effect already owns the generation pipeline: `Schema.toJsonSchemaDocument` produces Draft 2020-12 and `JsonSchema.toDocumentDraft07` lowers it. This package owns what [SchemaStore](https://www.schemastore.org) expects around that output — the publication shape (`$schema` + `$id` + root + `$defs`, with the `#/definitions` → `#/$defs` ref rewrite the lowering makes necessary), annotation carriers that keep the language-server keyword families alive through the lowering, catalog entries in both versioning modes, structural and hygiene lints, ajv strict-mode validation, canonical JSON text and content-comparing write-if-changed file IO. `SchemaPipeline` runs that whole emit loop over a list of targets, so a build script calls one function.
8
+ Build, version, validate and lint SchemaStore-shaped Draft-07 JSON Schema documents from Effect Schema sources. Core effect already owns the generation pipeline: `Schema.toJsonSchemaDocument` produces Draft 2020-12 and `JsonSchema.toDocumentDraft07` lowers it. This package owns what [SchemaStore](https://www.schemastore.org) expects around that output — the publication shape (`$schema` + `$id` + root + `$defs`, with the `#/definitions` → `#/$defs` ref rewrite the lowering makes necessary), the gate that holds a document's non-standard surface to the language-server keyword families it declares, catalog entries in both versioning modes, structural and hygiene lints, ajv strict-mode validation, canonical JSON text and content-comparing write-if-changed file IO. `SchemaPipeline` runs that whole emit loop over a list of targets, so a build script calls one function.
9
9
 
10
10
  > **Pre-release.** This package is part of the `@effected/*` kit, in pre-`1.0.0`
11
11
  > development against a single pinned Effect v4 prerelease. Packages graduate to
@@ -21,7 +21,7 @@ Build, version, validate and lint SchemaStore-shaped Draft-07 JSON Schema docume
21
21
 
22
22
  ## Why @effected/schemastore
23
23
 
24
- Generating a JSON Schema from an Effect Schema is a solved problem — core does it. Publishing that schema where editors find it is not. SchemaStore recommends Draft-07 because that is what the language servers actually support, and core's Draft-07 lowering has two consequences a publisher must deal with: it rewrites `$ref` pointers to the canonical `#/definitions/...` form while the published document keeps its pool under `$defs`, and it drops every keyword outside its fixed copy-list which is exactly where `markdownDescription`, `x-taplo` and the other editor keywords live. Around the document itself sits SchemaStore's own contract: ajv strict mode as the validation gate, catalog entries whose `fileMatch` patterns must not be generic and versioned schemas as suffixed files plus a `versions` map whose `url` points at the latest. This package is that last mile, so a build script does not have to reinvent it.
24
+ Generating a JSON Schema from an Effect Schema is a solved problem — core does it. Publishing that schema where editors find it is not. SchemaStore recommends Draft-07 because that is what the language servers actually support, and core's Draft-07 lowering has two consequences a publisher must deal with: it rewrites `$ref` pointers to the canonical `#/definitions/...` form while the published document keeps its pool under `$defs`, and it carries unknown and custom keywords through as opaque values so `markdownDescription`, `x-taplo` and the other editor keywords survive, and the publisher's problem is not getting them through but keeping anything *else* out of a document SchemaStore's own gate would reject. Around the document itself sits SchemaStore's own contract: ajv strict mode as the validation gate, catalog entries whose `fileMatch` patterns must not be generic and versioned schemas as suffixed files plus a `versions` map whose `url` points at the latest. This package is that last mile, so a build script does not have to reinvent it.
25
25
 
26
26
  The scope is deliberately narrow. There is no schema construction here, no ref resolution beyond the document's own `$defs` pool and no dialect conversion — core's `JsonSchema` owns the generation pipeline, ajv provides the validation gate and this package owns the SchemaStore shape in between.
27
27
 
@@ -75,7 +75,7 @@ console.log(Effect.runSync(program));
75
75
  // }
76
76
  ```
77
77
 
78
- `fromSchema` runs the whole pipeline — 2020-12 generation, Draft-07 lowering, the `$ref` rewrite and the annotation re-graft — 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.
78
+ `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
79
 
80
80
  ## Annotate at the definition site
81
81
 
@@ -83,7 +83,7 @@ One constraint bites consumers who do not know it, so it comes before the featur
83
83
 
84
84
  ## Carrying language-server annotations
85
85
 
86
- SchemaStore documents lean on non-standard keywords the editors read: the vscode-json-languageservice set (`markdownDescription`, `defaultSnippets`, `enumDescriptions`, `markdownEnumDescriptions`, `allowTrailingCommas`), taplo's `x-taplo` keys, tombi's `x-tombi-*` and IntelliJ's `x-intellij-*`. Effect Schema annotations accept arbitrary string keys, so `Schema.String.annotate({ "x-taplo": { ... } })` type-checks with no module augmentation but core's Draft-07 lowering copies a fixed keyword subset and would drop them. `StoreDocument.fromSchema` re-grafts the declared families onto the lowered document with a parallel walk (`AnnotationCarriers`), so the annotation you wrote is the keyword that ships:
86
+ SchemaStore documents lean on non-standard keywords the editors read: the vscode-json-languageservice set (`markdownDescription`, `defaultSnippets`, `enumDescriptions`, `markdownEnumDescriptions`, `allowTrailingCommas`), taplo's `x-taplo` keys, tombi's `x-tombi-*` and IntelliJ's `x-intellij-*`. Effect Schema annotations accept arbitrary string keys, so `Schema.String.annotate({ "x-taplo": { ... } })` type-checks with no module augmentation, and core's Draft-07 lowering carries the key through as an opaque value — in place, on the node you attached it to. `StoreDocument.fromSchema` admits the declared families unconditionally, so the annotation you wrote is the keyword that ships:
87
87
 
88
88
  ```ts
89
89
  import { StoreDocument } from "@effected/schemastore";
@@ -104,12 +104,18 @@ console.log(Effect.runSync(program));
104
104
  // => { name: { type: "string", "x-taplo": { docs: { main: "The display name." } } } }
105
105
  ```
106
106
 
107
- The declared families are always admitted — `KeywordFamilies` is the one registry, consumed by both the carriers and the lint, so the two cannot drift on what counts as declared. A caller-supplied `includeAnnotationKey` predicate is consulted in addition, but know the boundary: keys it admits outside the declared families reach the Draft 2020-12 document and are still dropped by the lowering.
107
+ The declared families are always admitted, whatever a caller-supplied `includeAnnotationKey` answers — `KeywordFamilies` is the one registry, consumed by both the gate and the lint, so the two cannot drift on what counts as declared. The predicate's only remaining effect is to fail the build: a key it admits outside the declared families raises `UndeclaredAnnotationKeyError`, naming every offending key. That is deliberate. This package emits SchemaStore-compatible documents only, and the families are the whole non-standard surface it will ship, so a predicate that reaches past them is a mistake worth hearing about rather than a preference worth honouring quietly.
108
+
109
+ One thing the package does *not* do to a declared-family value is walk inside it. The `#/definitions` → `#/$defs` `$ref` rewrite skips those payloads entirely: they are opaque advice addressed to a language server, so a `$ref`-shaped string inside one means whatever that tool says it means and survives verbatim.
110
+
111
+ `KeywordFamilies` splits into two groups. The upstream families above are mirrored from SchemaStore's own CONTRIBUTING guide — vocabularies the language servers already read. The `x-ai-` prefix (with the trailing dash — bare `x-ai` and a look-alike like `x-aida-foo` are not declared) is different: a house machine-annotation namespace this package owns rather than mirrors, meant for a machine reader rather than an editor. It is a namespace, not a fixed vocabulary — any key under the prefix is declared, and the one recommended (non-binding) key is `x-ai-hint`, a string instruction to a machine reader about the annotated value. A key under the prefix must be one ajv can register: after `x-ai-` only `[A-Za-z0-9_$:-]` (ajv holds a keyword name to `/^[a-z_$][a-z0-9_$:-]*$/i`), so a dot, a space, a slash, an `@`, a `+` or a non-ASCII character makes the engine gate reject the whole document as a finding. A declared-family value must itself be JSON, and must not contain an `$id` — or a repeated `$anchor` — at any depth, not merely as its own top-level key: ajv's reference collection walks unknown keywords looking for them, and a colliding one fails the compile. An empty-string `$id` resolves to the root id and collides too. No upstream tool sanctions `x-ai-` today, so it is intended for self-hosted publication rather than submission to schemastore.org without that repo's own validation-config entry. Because `x-ai-*` advises a reader rather than asserting anything, `DocumentDiff` classifies it as an annotation: adopting the family on an already-published versioned document rewrites that file in place, the same as any other prose change.
108
112
 
109
113
  ## Catalog entries and versioning
110
114
 
111
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.
112
116
 
117
+ `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
+
113
119
  `CatalogEntry.assemble` derives both catalog modes from the same inputs. Pass `versions` for the versioned mode — the `versions` map carries every label and `url` points at the latest version's file — or omit it for the unversioned single-file mode. An empty `versions` array is a contradiction and throws; pass `undefined` instead.
114
120
 
115
121
  ```ts
@@ -268,11 +274,34 @@ Both gates' findings normalize into one `PipelineFinding` shape, so a single pre
268
274
  SchemaPipeline.run(targets, { blocking: (finding) => finding.source === "validator" });
269
275
  ```
270
276
 
271
- Worth knowing which gate actually stops you here. A target carries a `Schema`, so pipeline documents come from `fromSchema`, and the Draft-07 lowering drops undeclared keywords before the lint ever sees them: `UnknownKeyword` is effectively unreachable through this entry point and **the engine gate is what blocks in practice**. The lint's warning checks earn their keep on depth and on documents the pipeline did not build, such as a hand-assembled `StoreDocument.draft07` or one read back off disk.
277
+ Worth knowing which gate actually stops you here. A target carries a `Schema`, so pipeline documents come from `fromSchema`, which never admits an undeclared keyword in the first place: `UnknownKeyword` is effectively unreachable through this entry point and **the engine gate is what blocks in practice**. The lint's warning checks earn their keep on depth and on documents the pipeline did not build, such as a hand-assembled `StoreDocument.draft07` or one read back off disk.
278
+
279
+ Findings come back as values and are never logged, so the wording of your build output stays yours. A blocking finding fails with `SchemaGateError` carrying every finding that blocked. `run` is **two-phase and all-or-nothing across targets**: every target is generated, gated, and — for a target the contract policy guards — classified against its predecessor before any file is touched, and only then are the held documents written, in order. A gate or contract failure on the third target therefore leaves the first two unwritten too, not merely the third; nothing is written unless every target passes both gates.
280
+
281
+ ### The contract gate
282
+
283
+ A schema with a pinned `version` — `SchemaVersioning.isPinned`'s name for a label with no prerelease — is a published document: consumers pin its URL. `SchemaPipeline.run` will not silently rewrite one in place when the new document's validation contract has moved out from under it: by default (`contractChanges: "block-versioned"`), a `"contract"` change on such a target fails the whole run with `SchemaContractChangeError` before anything is written, carrying every affected target's `$id`, `path`, `version` and the `nextVersion` it should publish under instead (`SchemaVersioning.next`). A target with no `version`, or with a prerelease label, declares its own instability and is rewritten in place as before.
284
+
285
+ ```ts
286
+ import { SchemaContractChangeError, SchemaPipeline } from "@effected/schemastore";
287
+ import { Effect } from "effect";
288
+
289
+ declare const targets: Parameters<typeof SchemaPipeline.run>[0];
290
+
291
+ const program = SchemaPipeline.run(targets).pipe(
292
+ Effect.catchTag("SchemaContractChangeError", (error: SchemaContractChangeError) =>
293
+ Effect.succeed(
294
+ error.targets.map((target) => `${target.$id}: ${target.version} -> ${target.nextVersion}`),
295
+ ),
296
+ ),
297
+ );
298
+ ```
299
+
300
+ Pass `contractChanges: "allow"` to classify and report only, never refuse — the same behavior the pipeline had before this gate existed. That is also the sanctioned repair path for a published file whose on-disk text no longer parses: `SchemaFile` classifies unparseable text as `"contract"` so a corrupted generated file stays regenerable, and the default policy would otherwise refuse that exact repair.
272
301
 
273
- Findings come back as values and are never logged, so the wording of your build output stays yours. A blocking finding fails with `SchemaGateError` carrying every finding that blocked, and the run stops there a gated document is never written, and neither are the targets after it.
302
+ The contract gate is only coherent when `version` participates in a target's `path` (`schemas/<version>/<name>-<version>.json`) a target at a fixed path compares the same file forever, so bumping `version` alone does not move where the next write lands.
274
303
 
275
- `SchemaPipeline.check(targets)` is the same walk with no writes, answering `wouldWrite`, `change` and `blocked` per target. Where `run` enforces, `check` reports: it is total over the targets and never stops at a failing gate, so a repo with several broken documents learns about all of them in one run rather than one per run. A blocked target is still never mistaken for clean drift, because `blocked` says so.
304
+ `SchemaPipeline.check(targets)` is the same walk with no writes, answering `wouldWrite`, `change`, `blocked` and `contractBlocked` per target. Where `run` enforces, `check` reports: it is total over the targets and never stops at a failing gate or a blocked contract, so a repo with several broken documents learns about all of them in one run rather than one per run. `blocked` and `contractBlocked` answer different questions side by side — the first, whether findings would block under `blocking`; the second, whether the contract policy would refuse the write under `contractChanges` so a drift job can print the right remedy instead of telling a refused target to "just regenerate."
276
305
 
277
306
  `runOne` and `checkOne` take a single target and answer its one result directly, so a one-target caller need not prove element zero exists.
278
307
 
@@ -289,13 +318,13 @@ The classification is key-order insensitive and keyword-position aware, like the
289
318
  ## Features
290
319
 
291
320
  - `StoreDocument` — the assembly pipeline: `fromSchema` / `fromSchemaResult`, the `draft07` constructor for hand-built documents, the flat `toJson()` publication shape, `serializeResult()`, the `DRAFT_07_META_SCHEMA` constant and `SchemaConversionError`.
292
- - `AnnotationCarriers` / `KeywordFamilies` — the post-lowering re-graft and the one registry of declared keyword families, consumed by both the carriers and the lint so they cannot disagree.
293
- - `SchemaVersioning` / `SchemaVersion` — full-SemVer version labels with `parseResult` / `parse`, the `Order` instance and `latest`, plus `fileName`, `schemaUrl` and `catalogUrls` deriving both catalog modes.
321
+ - `KeywordFamilies` — the one registry of declared keyword families (upstream language-server families plus the house `x-ai-` machine-annotation namespace), consumed by both the `fromSchema` gate and the lint so they cannot disagree.
322
+ - `SchemaVersioning` / `SchemaVersion` — full-SemVer version labels with `parseResult` / `parse`, the `Order` instance and `latest`, `isPinned` and `next` for the contract-change version bump, plus `fileName`, `schemaUrl` and `catalogUrls` deriving both catalog modes.
294
323
  - `CatalogEntry` — the `catalog.json` entry as a `Schema.Class`, `assemble` and the `fileMatch` hygiene lint (`CatalogLintFinding`).
295
324
  - `DocumentLint` — the total structural lint returning `DocumentLintFinding` values, never an error.
296
325
  - `SchemaValidator` — real-engine validation, closed by default over ajv: provide `SchemaValidator.layer` and it works. `ValidationFinding`, `SchemaValidatorError`, `noop` to switch validation off and the `makeTest` / `layerTest` doubles.
297
326
  - `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.
298
- - `SchemaPipeline` — the emit loop over a target manifest: `run` and `check`, the single-target `runOne` and `checkOne`, `PipelineFinding`, `SchemaGateError` and an overridable gating predicate.
327
+ - `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.
299
328
  - `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`.
300
329
  - `SchemaTarget` — the target manifest vocabulary: schema, `$id`, destination path, an optional name and an optional version that requires one.
301
330
  - `CanonicalJson` — the deterministic serializer with typed failures (`NonJsonValueError`, `JsonDepthExceededError`).
package/SchemaPipeline.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { SchemaVersion, SchemaVersioning } from "./SchemaVersioning.js";
1
2
  import { DocumentLint } from "./DocumentLint.js";
2
3
  import { SchemaFile } from "./SchemaFile.js";
3
4
  import { SchemaValidator } from "./SchemaValidator.js";
@@ -55,7 +56,38 @@ var SchemaGateError = class extends Schema.TaggedError()("SchemaGateError", {
55
56
  return `Schema "${this.$id}" failed its gate with ${this.findings.length} blocking finding(s)`;
56
57
  }
57
58
  };
59
+ /**
60
+ * One published document whose validation contract would change.
61
+ *
62
+ * @public
63
+ */
64
+ var ContractChangeTarget = class extends Schema.Class("ContractChangeTarget")({
65
+ /** The target's `$id`. */
66
+ $id: Schema.String,
67
+ /** The path the document would have been written to. */
68
+ path: Schema.String,
69
+ /** The target's pinned label. */
70
+ version: SchemaVersion,
71
+ /** The label to publish under instead — {@link SchemaVersioning.next} of `version`. */
72
+ nextVersion: SchemaVersion
73
+ }) {};
74
+ /**
75
+ * Indicates that at least one published target's contract changed under the
76
+ * active {@link ContractChangePolicy}. Raised BEFORE any target is written
77
+ * and total over the targets, so two broken documents surface in one run.
78
+ *
79
+ * @public
80
+ */
81
+ var SchemaContractChangeError = class extends Schema.TaggedError()("SchemaContractChangeError", {
82
+ /** Every published target whose contract would change, in target order. */
83
+ targets: Schema.Array(ContractChangeTarget) }) {
84
+ get message() {
85
+ return `${this.targets.length} published schema document(s) would change their validation contract; nothing was written. Bump each target's version, $id and path together — ${this.targets.map((target) => `${target.$id}: ${target.version} → ${target.nextVersion}`).join(", ")} — or pass contractChanges: "allow" to rewrite in place.`;
86
+ }
87
+ };
58
88
  const defaultBlocking = (finding) => finding.severity === "warning";
89
+ const contractGuardApplies = (target, options) => (options?.contractChanges ?? "block-versioned") === "block-versioned" && target.version !== void 0 && SchemaVersioning.isPinned(target.version);
90
+ const contractBlocks = (target, change, options) => contractGuardApplies(target, options) && change === "contract";
59
91
  const gather = (document, options) => Effect.gen(function* () {
60
92
  const validator = yield* SchemaValidator;
61
93
  const lint = DocumentLint.lint(document).map((finding) => PipelineFinding.make({
@@ -119,20 +151,72 @@ var SchemaPipeline = class SchemaPipeline {
119
151
  constructor() {}
120
152
  /**
121
153
  * Run every target: build its document, gather both gates' findings,
122
- * fail with a {@link SchemaGateError} if any block, and write otherwise.
154
+ * fail with a {@link SchemaGateError} if any block, refuse a published
155
+ * target whose contract changed, and write otherwise.
156
+ *
157
+ * **The run writes nothing unless every target passes both gates.** It
158
+ * is two-phase: every target is generated, gated and (for a target the
159
+ * {@link ContractChangePolicy} guards) compared against its predecessor
160
+ * before any file is touched, and only then are the held documents
161
+ * written in target order. A gate failure on the third target therefore
162
+ * leaves the first two unwritten.
163
+ *
164
+ * Phase 2 itself is sequential and **not transactional**: a filesystem or
165
+ * serialization failure part-way through it leaves the targets already
166
+ * written in place, with no rollback. The guarantee is "no writes unless
167
+ * every gate passes", not "every write or none".
168
+ *
169
+ * The gates run in a fixed precedence: {@link SchemaGateError} is
170
+ * fail-fast on the first blocked target, because a document the engine
171
+ * rejects would never be written under any contract policy, so its
172
+ * classification is noise. {@link SchemaContractChangeError} is total
173
+ * over the remaining targets, so two published documents whose contracts
174
+ * moved surface in one run.
175
+ *
176
+ * @example
177
+ * ```ts
178
+ * import { SchemaContractChangeError, SchemaPipeline } from "@effected/schemastore";
179
+ * import { Effect } from "effect";
180
+ *
181
+ * declare const targets: Parameters<typeof SchemaPipeline.run>[0];
123
182
  *
124
- * Targets are processed in order and the run stops at the first gate
125
- * failure a document that fails its gate is not written, and neither
126
- * are the targets after it.
183
+ * const program = SchemaPipeline.run(targets).pipe(
184
+ * Effect.catchTag("SchemaContractChangeError", (error: SchemaContractChangeError) =>
185
+ * Effect.succeed(
186
+ * error.targets.map((target) => `${target.$id}: ${target.version} -> ${target.nextVersion}`),
187
+ * ),
188
+ * ),
189
+ * );
190
+ * ```
127
191
  */
128
192
  static run(targets, options) {
129
193
  return Effect.gen(function* () {
130
194
  const files = yield* SchemaFile;
131
- const results = [];
195
+ const held = [];
196
+ const blocked = [];
132
197
  for (const target of targets) {
133
198
  const document = yield* StoreDocument.fromSchema(target.schema, { $id: target.$id });
134
199
  const findings = yield* gather(document, options);
135
200
  yield* gate(target, findings, options);
201
+ const version = target.version;
202
+ if (version !== void 0 && contractGuardApplies(target, options)) {
203
+ const { change } = yield* files.check(target.path, document, options?.write);
204
+ if (contractBlocks(target, change, options)) blocked.push(ContractChangeTarget.make({
205
+ $id: target.$id,
206
+ path: target.path,
207
+ version,
208
+ nextVersion: SchemaVersioning.next(version, "contract")
209
+ }));
210
+ }
211
+ held.push({
212
+ target,
213
+ document,
214
+ findings
215
+ });
216
+ }
217
+ if (blocked.length > 0) return yield* Effect.fail(SchemaContractChangeError.make({ targets: blocked }));
218
+ const results = [];
219
+ for (const { target, document, findings } of held) {
136
220
  const { outcome, change } = yield* files.write(target.path, document, options?.write);
137
221
  results.push({
138
222
  $id: target.$id,
@@ -172,6 +256,7 @@ var SchemaPipeline = class SchemaPipeline {
172
256
  path: target.path,
173
257
  wouldWrite,
174
258
  blocked: blockingFindings(findings, options).length > 0,
259
+ contractBlocked: contractBlocks(target, change, options),
175
260
  change,
176
261
  findings
177
262
  });
@@ -197,4 +282,4 @@ var SchemaPipeline = class SchemaPipeline {
197
282
  };
198
283
 
199
284
  //#endregion
200
- export { PipelineFinding, SchemaGateError, SchemaPipeline };
285
+ export { ContractChangeTarget, PipelineFinding, SchemaContractChangeError, SchemaGateError, SchemaPipeline };
@@ -95,9 +95,12 @@ var SchemaValidator = class SchemaValidator extends Context.Service()("@effected
95
95
  * `validate` checks the document against the Draft-07 meta-schema and
96
96
  * then compiles it, reporting BOTH as {@link ValidationFinding} values:
97
97
  * meta-schema failures keep ajv's structured `instancePath` and
98
- * `keyword`, while a strict-mode rejection (which ajv raises by throwing
99
- * at compile time) becomes a root-pathed finding. The error channel
100
- * stays reserved for the engine failing as a mechanism.
98
+ * `keyword`, while a rejection ajv raises by *throwing* becomes a
99
+ * root-pathed finding both a strict-mode compile failure and a
100
+ * declared keyword whose NAME ajv's own grammar
101
+ * (`/^[a-z_$][a-z0-9_$:-]*$/i`) refuses, such as an `x-ai-*` key
102
+ * carrying a dot or a space. The error channel stays reserved for the
103
+ * engine failing as a mechanism.
101
104
  *
102
105
  * `strict` defaults to `true` — SchemaStore's gate. Each call builds its
103
106
  * own ajv instance, so documents sharing an `$id` never collide.
@@ -110,9 +113,9 @@ var SchemaValidator = class SchemaValidator extends Context.Service()("@effected
110
113
  });
111
114
  const declared = /* @__PURE__ */ new Set();
112
115
  collectDeclaredKeywords(document, declared, 0);
113
- for (const keyword of declared) ajv.addKeyword({ keyword });
114
- if (!ajv.validateSchema(document)) return (ajv.errors ?? []).map(findingFromAjvError);
115
116
  try {
117
+ for (const keyword of declared) ajv.addKeyword({ keyword });
118
+ if (!ajv.validateSchema(document)) return (ajv.errors ?? []).map(findingFromAjvError);
116
119
  ajv.compile(document);
117
120
  } catch (cause) {
118
121
  return [ValidationFinding.make({
@@ -42,6 +42,13 @@ const orderingKey = (label) => {
42
42
  if (Result.isFailure(result)) throw new Error(`SchemaVersion ordering invariant violated for label "${label}"`);
43
43
  return result.success;
44
44
  };
45
+ const bumpBreaking = (current, parsed) => {
46
+ try {
47
+ return parsed.major === 0 ? parsed.bump.minor() : parsed.bump.major();
48
+ } catch (cause) {
49
+ throw new Error(`SchemaVersion bump invariant violated: "${current}" cannot be bumped past Number.MAX_SAFE_INTEGER (${Number.MAX_SAFE_INTEGER})`, { cause });
50
+ }
51
+ };
45
52
  const assertSimpleName = (name) => {
46
53
  if (name.length === 0 || /[/\\\s]/.test(name)) throw new Error(`Schema name must be a non-empty simple file base name, got "${name}"`);
47
54
  };
@@ -90,6 +97,55 @@ var SchemaVersioning = class SchemaVersioning {
90
97
  return versions.length === 0 ? Option.none() : Option.some(versions.reduce((max, v) => SchemaVersioning.Order(v, max) > 0 ? v : max));
91
98
  }
92
99
  /**
100
+ * Whether a label names a pinned, published document — i.e. it is NOT a
101
+ * prerelease. SemVer §9 makes a prerelease's own instability explicit, so
102
+ * a contract change inside one is not a break for anyone.
103
+ *
104
+ * ONE predicate consumed by two policies so they cannot drift:
105
+ * `SchemaPipeline`'s `"block-versioned"` guard (a pinned versioned target
106
+ * refuses an in-place contract change) and {@link SchemaVersioning.next}
107
+ * (a non-pinned label is not bumped). If the two used different tests, a
108
+ * caller could be refused a write AND told to keep the same label — a
109
+ * deadlock.
110
+ */
111
+ static isPinned(version) {
112
+ return orderingKey(version).prerelease.length === 0;
113
+ }
114
+ /**
115
+ * The version label a change classification calls for. Pure and
116
+ * synchronous; total over validated labels — a non-label input is a wiring
117
+ * bug and dies as a defect, the same as {@link SchemaVersioning.Order}.
118
+ *
119
+ * - `change !== "contract"` (`"none"`, `"annotations"`, `"created"`) →
120
+ * `current`. A created file has no predecessor to break; an annotation
121
+ * change is transparently replaceable (`DocumentDiff`).
122
+ * - `current` is not pinned (a prerelease) → `current`. A prerelease
123
+ * declares its own instability; the pipeline's `"block-versioned"`
124
+ * policy uses the same {@link SchemaVersioning.isPinned}, so the gate
125
+ * 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`).
129
+ *
130
+ * The bump's job is to be strictly greater and conspicuous, NOT to encode
131
+ * SemVer compatibility: `DocumentDiff` cannot tell an added optional
132
+ * property from a removed required one, so every contract change reads as
133
+ * breaking. Each label is its own file and URL, so an over-bump costs a
134
+ * file; an under-bump would overwrite a pinned document. `next` never
135
+ * introduces a prerelease from a stable input.
136
+ *
137
+ * A component that would be bumped past `Number.MAX_SAFE_INTEGER` cannot be
138
+ * represented, and throws this module's explicit invariant `Error` naming
139
+ * the label and the ceiling rather than `SemVer.make`'s bare schema failure.
140
+ */
141
+ static next(current, change) {
142
+ if (change !== "contract" || !SchemaVersioning.isPinned(current)) return current;
143
+ const label = bumpBreaking(current, orderingKey(current)).toString();
144
+ const reparsed = SchemaVersioning.parseResult(label);
145
+ if (Result.isFailure(reparsed)) throw new Error(`SchemaVersion bump invariant violated: "${current}" bumped to "${label}"`);
146
+ return reparsed.success;
147
+ }
148
+ /**
93
149
  * Derives the schema file name for a catalog name: `name.json`
94
150
  * unversioned, `name-<version>.json` versioned.
95
151
  *
package/StoreDocument.js CHANGED
@@ -1,6 +1,5 @@
1
- import { KeywordFamilies } from "./KeywordFamilies.js";
2
- import { AnnotationCarriers } from "./AnnotationCarriers.js";
3
1
  import { CanonicalJson } from "./CanonicalJson.js";
2
+ import { KeywordFamilies } from "./KeywordFamilies.js";
4
3
  import { Effect, JsonSchema, Result, Schema } from "effect";
5
4
 
6
5
  //#region src/StoreDocument.ts
@@ -34,21 +33,51 @@ var SchemaConversionError = class extends Schema.TaggedError()("SchemaConversion
34
33
  return `Failed to build SchemaStore document "${this.$id}"`;
35
34
  }
36
35
  };
36
+ /**
37
+ * Indicates that a caller-supplied `includeAnnotationKey` admitted an
38
+ * annotation key outside the declared keyword families
39
+ * ({@link KeywordFamilies}).
40
+ *
41
+ * Raised by {@link StoreDocument.fromSchema}. This package emits
42
+ * SchemaStore-compatible documents only, so the declared families are the
43
+ * whole permitted non-standard surface. A key outside them is refused
44
+ * loudly rather than emitted — which would ship a document SchemaStore's
45
+ * own gate rejects — or silently omitted, which would hide the mistake in
46
+ * the caller's predicate.
47
+ *
48
+ * The predicate itself cannot be introspected, so the offending keys are
49
+ * the ones it actually admitted while the document was being generated: a
50
+ * key the source schema never annotates cannot appear here.
51
+ *
52
+ * @public
53
+ */
54
+ var UndeclaredAnnotationKeyError = class extends Schema.TaggedError()("UndeclaredAnnotationKeyError", {
55
+ /** The `$id` of the document that was being built. */
56
+ $id: Schema.String,
57
+ /** The offending keys, deduplicated and sorted. */
58
+ keys: Schema.Array(Schema.String)
59
+ }) {
60
+ get message() {
61
+ const keys = this.keys.map((key) => `"${key}"`).join(", ");
62
+ return `Document "${this.$id}" admits annotation keys outside the declared families: ${keys}`;
63
+ }
64
+ };
37
65
  const DEFINITIONS_REF_PREFIX = /^#\/definitions(?=\/|$)/;
38
66
  var RewriteDepthExceeded = class {
39
67
  _tag = "RewriteDepthExceeded";
40
68
  };
41
- const carry = (source, target) => {
42
- const result = AnnotationCarriers.carryResult(source, target);
43
- if (Result.isFailure(result)) throw result.failure;
44
- return result.success;
45
- };
46
69
  const restoreDefsRefs = (node, depth) => {
47
70
  if (depth >= 256) throw new RewriteDepthExceeded();
48
71
  if (Array.isArray(node)) return node.map((item) => restoreDefsRefs(item, depth + 1));
49
72
  if (typeof node === "object" && node !== null) {
50
73
  const out = Object.create(null);
51
- for (const [key, value] of Object.entries(node)) out[key] = key === "$ref" && typeof value === "string" ? value.replace(DEFINITIONS_REF_PREFIX, "#/$defs") : restoreDefsRefs(value, depth + 1);
74
+ for (const [key, value] of Object.entries(node)) {
75
+ if (KeywordFamilies.isDeclared(key)) {
76
+ out[key] = value;
77
+ continue;
78
+ }
79
+ out[key] = key === "$ref" && typeof value === "string" ? value.replace(DEFINITIONS_REF_PREFIX, "#/$defs") : restoreDefsRefs(value, depth + 1);
80
+ }
52
81
  return out;
53
82
  }
54
83
  return node;
@@ -63,9 +92,17 @@ const restoreDefsRefs = (node, depth) => {
63
92
  * `JsonSchema.toDocumentDraft07` lowering, the `#/definitions` →
64
93
  * `#/$defs` `$ref` rewrite the lowering makes necessary — so every `$ref`
65
94
  * in a built document already resolves against the `$defs` pool — and the
66
- * {@link AnnotationCarriers} re-graft, so annotated non-standard keyword
67
- * families ({@link KeywordFamilies}) survive into the built document. The
68
- * package owns assembly and publication shape, not a JSON Schema engine.
95
+ * gate that holds the document's non-standard surface to the declared
96
+ * keyword families ({@link KeywordFamilies}). The package owns assembly and
97
+ * publication shape, not a JSON Schema engine.
98
+ *
99
+ * Annotated declared-family keys survive into the built document because
100
+ * core's Draft-07 lowering copies unknown and custom keywords through as
101
+ * opaque values, in place, including across the tuple coordinate move
102
+ * (2020-12 `prefixItems[i]` → Draft-07 `items[i]`, trailing `items` →
103
+ * `additionalItems`). This package therefore does no re-grafting of its
104
+ * own; it only declines to rewrite `$ref`-shaped strings *inside* those
105
+ * opaque payloads.
69
106
  *
70
107
  * @public
71
108
  */
@@ -117,14 +154,23 @@ var StoreDocument = class StoreDocument extends Schema.Class("StoreDocument")({
117
154
  static fromSchemaResult(source, options) {
118
155
  try {
119
156
  const userIncludes = options.jsonSchema?.includeAnnotationKey;
157
+ const undeclared = /* @__PURE__ */ new Set();
120
158
  const document = Schema.toJsonSchemaDocument(source, {
121
159
  ...options.jsonSchema,
122
- includeAnnotationKey: (key) => KeywordFamilies.isDeclared(key) || userIncludes?.(key) === true
160
+ includeAnnotationKey: (key) => {
161
+ if (KeywordFamilies.isDeclared(key)) return true;
162
+ if (userIncludes?.(key) === true) undeclared.add(key);
163
+ return false;
164
+ }
123
165
  });
166
+ if (undeclared.size > 0) return Result.fail(UndeclaredAnnotationKeyError.make({
167
+ $id: options.$id,
168
+ keys: [...undeclared].sort()
169
+ }));
124
170
  const lowered = JsonSchema.toDocumentDraft07(document);
125
- const root = carry(document.schema, restoreDefsRefs(lowered.schema, 0));
171
+ const root = restoreDefsRefs(lowered.schema, 0);
126
172
  const defs = Object.create(null);
127
- for (const [name, definition] of Object.entries(lowered.definitions)) defs[name] = carry(document.definitions[name], restoreDefsRefs(definition, 1));
173
+ for (const [name, definition] of Object.entries(lowered.definitions)) defs[name] = restoreDefsRefs(definition, 1);
128
174
  return Result.succeed(StoreDocument.make({
129
175
  $schema: DRAFT_07_META_SCHEMA,
130
176
  $id: options.$id,
@@ -169,4 +215,4 @@ var StoreDocument = class StoreDocument extends Schema.Class("StoreDocument")({
169
215
  };
170
216
 
171
217
  //#endregion
172
- export { DRAFT_07_META_SCHEMA, SchemaConversionError, StoreDocument };
218
+ export { DRAFT_07_META_SCHEMA, SchemaConversionError, StoreDocument, UndeclaredAnnotationKeyError };