@effected/schemastore 0.11.0 → 0.13.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/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), 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.
8
+ Publish Effect Schemas as SchemaStore-shaped Draft-07 JSON Schema documents. Core `effect` already generates JSON Schema (`Schema.toJsonSchemaDocument`) and lowers it to Draft-07 (`JsonSchema.toDocumentDraft07`); this package owns what [SchemaStore](https://www.schemastore.org) and the editors expect around that output — the publication shape, the hosted identity a document is published under, the keyword-family gate, catalog entries, lints, versioning, canonical JSON and content-comparing file IO — and the [`schemastore`](https://www.npmjs.com/package/@effected/schemastore-cli) command runs all of it from one config file.
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
@@ -19,224 +19,96 @@ Build, version, validate and lint SchemaStore-shaped Draft-07 JSON Schema docume
19
19
  > accident, and an exact pin turns that into a type-check error rather than a
20
20
  > runtime surprise. Full policy: [release strategy](https://github.com/spencerbeggs/effected#release-strategy).
21
21
 
22
- ## Why @effected/schemastore
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 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
-
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
-
28
22
  ## Install
29
23
 
30
- ```bash
31
- npm install @effected/schemastore effect
32
- ```
24
+ Two packages, one version, two roles: this library is a regular dependency of the application that publishes a schema (its code reads the schema's identity at runtime), and the command is a devDependency that builds and checks the documents.
33
25
 
34
26
  ```bash
35
27
  pnpm add @effected/schemastore effect
28
+ pnpm add -D @effected/schemastore-cli
36
29
  ```
37
30
 
38
- Requires Node.js >=24.11.0.
39
-
40
- All `@effected/*` packages are ESM-only: the exports maps publish only `import` conditions, so `require()` — including tools that resolve in CJS mode — fails with Node's `ERR_PACKAGE_PATH_NOT_EXPORTED` rather than loading a CJS build that does not exist. Import from an ES module.
41
-
42
- `effect` v4 is the only peer dependency. Two regular dependencies ride along: `@effected/semver` does the version ordering inside `SchemaVersioning`, with no `SemVer` type surfacing in the public API, and `ajv` is the engine behind `SchemaValidator.layer`. ajv therefore arrives with this package and the validation examples below need no extra install; if your own code imports ajv directly, depend on it directly rather than relying on this one's copy. Every module is pure except `SchemaFile`, whose layer requires core `FileSystem` and `Path`, supplied at the edge from `@effect/platform-node` or `@effect/platform-bun`.
31
+ Requires Node.js >=24.11.0. ESM-only. `effect` v4 is the only peer; `@effected/semver` (version ordering) is the only runtime dependency. There is no validation engine here — `SchemaValidator` is a contract, and the shipped ajv strict-mode engine is `AjvValidator` in the CLI — so importing this package at runtime never installs or bundles ajv.
43
32
 
44
- ## Quick start
33
+ ## How the two packages fit together
45
34
 
46
- Turn an Effect Schema into a publication-ready document:
35
+ The pattern has three parts. The application declares each schema's **hosted identity** once, next to the schema, and reads the `$schema` URL it writes into its own output from it. The config file hands the same values to `defineConfig`. The command derives every path, `$id` and catalog URL from them, so nothing is spelled twice.
47
36
 
48
37
  ```ts
49
- import { StoreDocument } from "@effected/schemastore";
50
- import { Effect, Schema } from "effect";
51
-
52
- const Config = Schema.Struct({ name: Schema.String });
38
+ // src/schema/output.ts — the application
39
+ import { HostedSchema } from "@effected/schemastore";
40
+ import { Schema } from "effect";
53
41
 
54
- const program = Effect.gen(function* () {
55
- const document = yield* StoreDocument.fromSchema(Config, {
56
- $id: "https://example.com/config.schema.json",
57
- });
58
- return yield* Effect.fromResult(document.serializeResult());
59
- });
60
-
61
- console.log(Effect.runSync(program));
62
- // {
63
- // "$schema": "http://json-schema.org/draft-07/schema#",
64
- // "$id": "https://example.com/config.schema.json",
65
- // "type": "object",
66
- // "properties": {
67
- // "name": {
68
- // "type": "string"
69
- // }
70
- // },
71
- // "required": [
72
- // "name"
73
- // ],
74
- // "additionalProperties": true
75
- // }
76
- ```
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
-
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.
81
-
82
- ## Annotate at the definition site
83
-
84
- One constraint bites consumers who do not know it, so it comes before the feature tour. An annotation applied at a hoisted schema's *usage* site (`Person.annotate({ ... })` inside a struct field) reaches neither the `$ref` node nor the `$defs` pool entry, even before the Draft-07 lowering. It silently carries nothing. Put the annotation where the schema is defined.
85
-
86
- ## Carrying language-server annotations
87
-
88
- 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:
89
-
90
- ```ts
91
- import { StoreDocument } from "@effected/schemastore";
92
- import { Effect, Schema } from "effect";
42
+ export const OUTPUT_SCHEMA_VERSION = "5.2";
93
43
 
94
- const Config = Schema.Struct({
95
- name: Schema.String.annotate({ "x-taplo": { docs: { main: "The display name." } } }),
44
+ export const OutputSchemaIdentity = HostedSchema.github({
45
+ repo: "savvy-web/silk-release-action",
46
+ path: "schemas",
47
+ name: "silk-release-action.output",
48
+ versions: [OUTPUT_SCHEMA_VERSION],
96
49
  });
97
50
 
98
- const program = Effect.gen(function* () {
99
- const document = yield* StoreDocument.fromSchema(Config, {
100
- $id: "https://example.com/config.schema.json",
101
- });
102
- return document.root.properties;
51
+ // Every payload the application emits names the document it was written against.
52
+ export const ReleaseOutput = Schema.Struct({
53
+ $schema: Schema.Literal(OutputSchemaIdentity.$id),
54
+ status: Schema.Literals(["released", "skipped"]),
103
55
  });
104
-
105
- console.log(Effect.runSync(program));
106
- // => { name: { type: "string", "x-taplo": { docs: { main: "The display name." } } } }
107
56
  ```
108
57
 
109
- 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.
110
-
111
- 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.
112
-
113
- `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.
114
-
115
- ## Catalog entries and versioning
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 **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
-
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
-
121
- `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.
122
-
123
58
  ```ts
124
- import { CatalogEntry, SchemaVersioning } from "@effected/schemastore";
125
- import { Effect } from "effect";
126
-
127
- const program = Effect.gen(function* () {
128
- const versions = yield* Effect.forEach(["1.9.0", "1.10.0"], SchemaVersioning.parse);
129
- return CatalogEntry.assemble({
130
- name: "My Tool",
131
- description: "Configuration for My Tool.",
132
- fileMatch: ["mytool.config.json"],
133
- baseUrl: "https://example.com/schemas",
134
- fileBaseName: "mytool",
135
- versions,
136
- });
59
+ // schemastore.config.ts — the config
60
+ import { defineConfig } from "@effected/schemastore";
61
+ import { OutputSchemaIdentity, ReleaseOutput } from "./src/schema/output.js";
62
+
63
+ export default defineConfig({
64
+ outputDir: "schemas",
65
+ schemas: {
66
+ [OutputSchemaIdentity.name]: { schema: ReleaseOutput, hosted: OutputSchemaIdentity },
67
+ },
137
68
  });
138
-
139
- console.log(Effect.runSync(program).url);
140
- // => "https://example.com/schemas/mytool-1.10.0.json"
141
69
  ```
142
70
 
143
- The `fileMatch` hygiene lint enforces the patterns SchemaStore's reviewers enforce, as pure shape analysis — it never matches a pattern against a path, so there is no glob engine behind it:
144
-
145
- ```ts
146
- import { CatalogEntry } from "@effected/schemastore";
147
-
148
- const findings = CatalogEntry.lintFileMatch(["config.toml", "**/{a,b}.json"]);
149
-
150
- console.log(findings.map((finding) => finding.check));
151
- // => ["GenericFileMatch", "ComplexFileMatch"]
71
+ ```json
72
+ {
73
+ "scripts": {
74
+ "schema:build": "schemastore build",
75
+ "schema:check": "schemastore check"
76
+ }
77
+ }
152
78
  ```
153
79
 
154
- `GenericFileMatch` flags patterns matching generic names other tools also use (SchemaStore rejects them); `ComplexFileMatch` flags glob constructs like alternations that should be expanded into multiple simple patterns. `entry.lint()` runs the same checks over an assembled entry.
155
-
156
- ## Linting documents
157
-
158
- `DocumentLint.lint` is the owned, always-available half of the validation story: total structural checks returning findings as values, never an error — hostile nesting degrades to a finding too.
80
+ `schemastore build` writes `schemas/5.2/silk-release-action.output-5.2.json` with `$id` equal to `OutputSchemaIdentity.$id`, closed objects (`additionalProperties: false`), and the `$schema` literal the application asserts. `schemastore check` is the CI gate: it fails when a build would write anything. Bumping the version is one constant. Everything the command does — the drift policy, the `published` flag, frozen labels, the catalog file, exit codes — is documented on the [CLI's page](https://www.npmjs.com/package/@effected/schemastore-cli).
159
81
 
160
- | Check | Severity | Fires when |
161
- | ----- | -------- | ---------- |
162
- | `UnresolvedRef` | warning | a `$ref` does not resolve against the `$defs` pool — including a `#/definitions/...` pointer that survived where it should not |
163
- | `UnknownKeyword` | warning | a keyword sits outside Draft-07 plus the declared non-standard families, which ajv strict mode would reject |
164
- | `DescriptionWithoutUrl` | advisory | the root description's last line is not a documentation URL (SchemaStore's description convention) |
165
- | `DepthExceeded` | warning | nesting exceeds the depth cap; the walk stops there instead of failing |
82
+ `HostedSchema.github({ repo, branch?, path?, ... })` serves files raw from a repository (`branch` defaults to `main`); `HostedSchema.schemastore({ name, ... })` publishes to SchemaStore (`$id` on `json.schemastore.org`, the catalog URL on `www.schemastore.org`, flat layout); `HostedSchema.custom({ baseUrl, ... })` takes any `https://` directory as a string or `URL`. Each validates the identity — `current` must be one of `versions`, two spellings of one label are refused — and throws a plain `Error` naming the reason. `$id`, `url` and `fileName` answer the current document; `idFor`, `urlFor` and `fileNameFor` answer any advertised version. Versioned files carry SchemaStore's `-<version>` suffix by default; when the version directory should name the file alone (`schemas/6.0/output.json`, natural when the repository already names the tool), pass `appendVersion: false` — it needs the `"versioned"` layout, since under `"flat"` every version would share one file name. A consumer test pins `target.path === \`${outputDir}/${identity.fileName}\`` to catch a swapped `hosted:` the CLI cannot see.
166
83
 
167
- The keyword walk is position-aware: a *property* named `unevaluatedProperties` is data, not a keyword, and is not flagged; `enum`, `const`, `default` and `examples` values are never descended into.
84
+ ## Using the library directly
168
85
 
169
- ## Real-engine validation
86
+ Everything the command composes is exported, for a program that needs one piece or wants to drive the pipeline itself.
170
87
 
171
- SchemaStore's own gate is ajv strict mode, and this package ships it. `SchemaValidator.layer` is a real ajv implementation: provide it and validation works, with no adapter to write. ajv is a direct dependency because SchemaStore's gate *is* ajv, and this package is build-time tooling for emitting documents that clear that gate. Keeping the engine out of the graph bought nothing and left every consumer writing the same adapter.
172
-
173
- The channel convention holds: findings are values — a strict-mode rejection is a report, not an error — and the error channel is reserved for the engine failing as a mechanism (`SchemaValidatorError`). Meta-schema failures keep ajv's structured `instancePath` and `keyword`; a strict-mode rejection, which ajv raises by throwing, becomes a root-pathed finding. The declared language-server keyword families are registered before compiling, so ajv does not reject what `DocumentLint` deliberately allows — one `KeywordFamilies` predicate governs both verdicts.
88
+ `StoreDocument.fromSchema` runs the assembly for one schema — 2020-12 generation, Draft-07 lowering, the `#/definitions` → `#/$defs` rewrite and the keyword-family gate — so every `$ref` in a built document resolves against its `$defs` pool:
174
89
 
175
90
  ```ts
176
- import { SchemaValidator, StoreDocument } from "@effected/schemastore";
91
+ import { StoreDocument } from "@effected/schemastore";
177
92
  import { Effect, Schema } from "effect";
178
93
 
179
- const program = Effect.gen(function* () {
180
- const validator = yield* SchemaValidator;
181
- const document = yield* StoreDocument.fromSchema(Schema.Struct({ name: Schema.String }), {
182
- $id: "https://example.com/config.schema.json",
183
- });
184
- return yield* validator.validate(document.toJson());
185
- });
186
-
187
- Effect.runPromise(Effect.provide(program, SchemaValidator.layer)).then(console.log);
188
- // [] when the document compiles clean; the engine's findings otherwise
189
- ```
190
-
191
- The service stays an interface: `noop` switches validation off deliberately, `makeTest` / `layerTest` are the doubles (unstubbed members die naming themselves) and a consumer standardized on another engine can substitute one. `DocumentLint` remains the engine-free structural half, answering SchemaStore hygiene questions ajv does not.
192
-
193
- `validate` takes the flat serialized record — `StoreDocument.toJson()`'s shape — so the seam stays engine-shaped and decoupled from this package's classes.
194
-
195
- ## Writing schema files
196
-
197
- `SchemaFile` is the package's one IO surface: serialize through the canonical serializer, compare against what is on disk and write only on difference, creating parent directories as needed. The result is a value, never a log.
198
-
199
- The comparison is by **content**, not bytes, so a generated schema can share a file with a formatter that also owns it. If your repo's Biome or Prettier hook reflows the emitted JSON, the next run still reports `"unchanged"` and leaves the file alone, and you write no exclusion rule. Pass `compare: "bytes"` to opt back into byte-exactness when the emitted text is itself the artifact.
200
-
201
- `write` also says what the difference *meant*: `"annotations"` when only prose and editor affordances moved, so the document replaces its predecessor transparently and needs no new version, and `"contract"` when an assertion keyword moved and a consumer's valid document may now be invalid. `check` makes the same comparison without writing, which is what a CI drift job wants:
202
-
203
- ```ts
204
- import { SchemaFile, StoreDocument } from "@effected/schemastore";
205
- import { NodeFileSystem, NodePath } from "@effect/platform-node";
206
- import { Effect, Layer, Schema } from "effect";
94
+ const Config = Schema.Struct({ name: Schema.String });
207
95
 
208
96
  const program = Effect.gen(function* () {
209
- const files = yield* SchemaFile;
210
- const document = yield* StoreDocument.fromSchema(Schema.Struct({ name: Schema.String }), {
97
+ const document = yield* StoreDocument.fromSchema(Config, {
211
98
  $id: "https://example.com/config.schema.json",
212
99
  });
213
- const first = yield* files.write("schemas/config.schema.json", document);
214
- const second = yield* files.write("schemas/config.schema.json", document);
215
- const drift = yield* files.check("schemas/config.schema.json", document);
216
- return [first, second, drift] as const;
217
- }).pipe(
218
- Effect.provide(SchemaFile.layer),
219
- Effect.provide(Layer.mergeAll(NodeFileSystem.layer, NodePath.layer)),
220
- );
221
-
222
- Effect.runPromise(program).then(console.log);
223
- // => [
224
- // { outcome: "written", change: "created" },
225
- // { outcome: "unchanged", change: "none" },
226
- // { wouldWrite: false, change: "none" },
227
- // ]
100
+ return yield* Effect.fromResult(document.serializeResult());
101
+ });
102
+ // { "$schema": "http://json-schema.org/draft-07/schema#", "$id": "…", "type": "object", …, "additionalProperties": false }
228
103
  ```
229
104
 
230
- `outcome` and `wouldWrite` are the authoritative answers to whether the file was or would be touched. Never infer that from `change`, which reports content and reads `"none"` on a `compare: "bytes"` write that did rewrite the file.
231
-
232
- Failures stay typed and apart: `SchemaFileNotFoundError` for a missing file on `read`, `SchemaFileReadError` when the comparison read fails for any other reason (the write fails rather than silently overwriting), `SchemaFileWriteError` for the filesystem write and `CanonicalJsonError` when the document does not serialize.
105
+ Objects are closed by default — a published document is a contract, and this package does not follow core's open default. `jsonSchema: { onExcessProperty: "ignore" }` on one target (or one `defineConfig` entry) reopens that document.
233
106
 
234
- ## The emit pipeline
235
-
236
- `SchemaPipeline` is the loop around everything above: generate each target's document, lint it, validate it with the engine, gate on the findings, write it. It is a plain function requiring `SchemaFile` and `SchemaValidator`, not another service to wire.
107
+ `SchemaPipeline.run(targets)` is the emit loop over a target manifest — generate, lint, validate, gate, write — requiring `SchemaFile` and `SchemaValidator` in `R`. Provide the file service and an engine at the edge; the engine is the CLI's:
237
108
 
238
109
  ```ts
239
- import { SchemaFile, SchemaPipeline, SchemaTarget, SchemaValidator } from "@effected/schemastore";
110
+ import { SchemaFile, SchemaPipeline, SchemaTarget } from "@effected/schemastore";
111
+ import { AjvValidator } from "@effected/schemastore-cli";
240
112
  import { NodeServices } from "@effect/platform-node";
241
113
  import { Effect, Layer, Schema } from "effect";
242
114
 
@@ -245,103 +117,32 @@ const targets = [
245
117
  schema: Schema.Struct({ name: Schema.String }),
246
118
  $id: "https://example.com/config.schema.json",
247
119
  path: "schemas/config.schema.json",
248
- jsonSchema: { onExcessProperty: "error" },
249
120
  }),
250
121
  ];
251
122
 
252
- // One named layer, composed once, provided at the boundary.
253
- const AppLayer = Layer.mergeAll(SchemaFile.layer, SchemaValidator.layer).pipe(
254
- Layer.provide(NodeServices.layer),
255
- );
256
-
257
- const program = SchemaPipeline.run(targets).pipe(Effect.provide(AppLayer));
258
-
259
- Effect.runPromise(program).then(console.log);
260
- // => [{ $id, path, outcome: "written", change: "created", findings: [] }]
261
- ```
262
-
263
- `NodeServices` is composed **into** the layer with `Layer.provide` rather than
264
- stacked onto the program with a second `Effect.provide`. Both run correctly
265
- here — `SchemaFile` holds no state, so nothing observes the difference — but
266
- the composed form is the shape to copy. Stacking `Effect.provide` calls at the
267
- call site is how a layer ends up built more than once, and the first stateful
268
- service you add is where that starts to matter. Binding the composition to a
269
- named `const` also makes it reusable: a drift test and the generator that
270
- provide the same value cannot disagree about what the layer contains.
271
-
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`.
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
-
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.
277
-
278
- ```ts
279
- SchemaPipeline.run(targets, { blocking: (finding) => finding.source === "validator" });
280
- ```
281
-
282
- 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.
283
-
284
- 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.
285
-
286
- ### The contract gate
287
-
288
- 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.
289
-
290
- ```ts
291
- import { SchemaContractChangeError, SchemaPipeline } from "@effected/schemastore";
292
- import { Effect } from "effect";
293
-
294
- declare const targets: Parameters<typeof SchemaPipeline.run>[0];
295
-
296
123
  const program = SchemaPipeline.run(targets).pipe(
297
- Effect.catchTag("SchemaContractChangeError", (error: SchemaContractChangeError) =>
298
- Effect.succeed(
299
- error.targets.map((target) => `${target.$id}: ${target.version} -> ${target.nextVersion}`),
300
- ),
301
- ),
124
+ Effect.provide(Layer.mergeAll(SchemaFile.layer, AjvValidator.layer).pipe(Layer.provide(NodeServices.layer))),
302
125
  );
303
126
  ```
304
127
 
305
- 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.
306
-
307
- 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.
308
-
309
- `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."
310
-
311
- `runOne` and `checkOne` take a single target and answer its one result directly, so a one-target caller need not prove element zero exists.
312
-
313
- ## Comparing two documents
314
-
315
- `DocumentDiff.classify` is the pure form of the comparison `SchemaFile` makes internally: hand it two emitted documents and it answers `"none"`, `"annotations"` or `"contract"`. That is the signal for whether a change needs a new schema version — `"annotations"` replaces its predecessor transparently, `"contract"` does not. `DocumentDiff.isClean` is the predicate for the clean case, so consumers do not spell `"none"` themselves; `"created"` is deliberately not clean.
316
-
317
- The classification is key-order insensitive and keyword-position aware, like the lint. `default`, `examples`, `readOnly` and `writeOnly` count as contract rather than documentation, because consumers act on them: reporting a contract change as annotations ships a silent break, while the reverse only costs a version bump.
318
-
319
- ## Canonical JSON
320
-
321
- `CanonicalJson` is the deterministic serializer behind `serializeResult` and `SchemaFile.write`: insertion-order keys (assembly owns ordering — nothing is sorted), tab indentation by default, LF line endings and a single trailing newline, so equal documents serialize to equal bytes. Where `JSON.stringify` silently drops or rewrites `undefined`, `NaN` and non-plain objects, it fails typed instead — `NonJsonValueError` carries a JSON pointer to the offending value, and `JsonDepthExceededError` catches hostile nesting and cycles.
322
-
323
- `CanonicalJson.equals` is content equality under the same semantics: two values are equal when they would parse to the same JSON document — object key order is ignored, arrays compare positionally, and a non-plain object (a class instance, a `Date`) compares by reference. It is the comparison `SchemaFile`'s write-if-changed and `DocumentDiff`'s leaf checks already make, exported so a consumer writing its own JSON artifact can decide "unchanged" by the same rule.
324
-
325
- ## Root annotations
326
-
327
- Some annotations cannot be expressed on the source schema — a `Schema.Class` root's `title`, or a description on a field the generator filtered. `rootAnnotations` (on `StoreDocumentOptions`, and forwarded from `SchemaTarget.rootAnnotations` by the pipeline) merges them onto the emitted root after assembly. The gate is up front: only the standard annotation keywords (`title`, `description`, `$comment`, `default`, `examples`, `readOnly`, `writeOnly`, `contentMediaType`, `contentEncoding`) and the declared keyword families are admitted; anything else fails with `UndeclaredAnnotationKeyError` before generation, so the override cannot become a back door for assertion keywords.
328
-
329
- Placement follows the assembled root: an inline root takes the annotations directly; a bare local `$ref` root whose `$defs` entry nothing else references takes them on that entry (Draft-07 validators ignore `$ref` siblings); a bare `$ref` root whose entry is shared — a recursive class — becomes `{ ...annotations, allOf: [{ $ref }] }`, so the document is annotated without every occurrence of the type inheriting its title.
128
+ `run` is two-phase and all-or-nothing across targets: every target is generated, gated and — for a pinned version — classified against its predecessor before any file is written. A blocking finding fails with `SchemaGateError`; a contract change on a pinned, published version fails with `SchemaContractChangeError` carrying the `nextVersion` to publish under instead (`contractChanges: "allow"` classifies and reports only). `check` is the same walk with no writes. Findings are values, never logs, and the gating predicate (`blocking`) is yours to replace.
330
129
 
331
- ## Features
130
+ ## What the library owns
332
131
 
333
- - `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`.
334
- - `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.
335
- - `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.
336
- - `CatalogEntry` — the `catalog.json` entry as a `Schema.Class`, `assemble` and the `fileMatch` hygiene lint (`CatalogLintFinding`).
337
- - `DocumentLint` — the total structural lint returning `DocumentLintFinding` values, never an error.
338
- - `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.
339
- - `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.
340
- - `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.
341
- - `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`.
342
- - `SchemaTarget` — the target manifest vocabulary: schema, `$id`, destination path, an optional name, an optional version that requires one, optional per-target `jsonSchema` generation options and `rootAnnotations` merged onto the emitted root.
343
- - `CanonicalJson` — the deterministic serializer with typed failures (`NonJsonValueError`, `JsonDepthExceededError`) and `equals`, content equality under the same semantics.
132
+ - `HostedSchema` — a schema's hosted identity (`github`, `schemastore`, `custom`), deriving `$id`, the catalog URL and the file name for the current or any advertised version.
133
+ - `defineConfig` — the `schemastore.config.ts` contract: validated with one `Schema.Struct` per level (a typo'd key is named, every issue on an entry reported at once), every path and URL derived from the entry key and its identity, frozen labels resolved, a branded result the CLI recognises.
134
+ - `StoreDocument` — assembly: `fromSchema` / `fromSchemaResult`, the `draft07` constructor for hand-built documents, the flat `toJson()` publication shape, `serializeResult()`, `DRAFT_07_META_SCHEMA`.
135
+ - `KeywordFamilies` — the one registry of declared non-standard keyword families (the vscode five, `x-taplo`, `x-tombi-`, `x-intellij-`, and the house `x-ai-` machine-annotation namespace). Anything outside it fails `fromSchema` with `UndeclaredAnnotationKeyError`; nothing is silently dropped.
136
+ - `SchemaVersioning` / `SchemaVersion` — one-to-three-component version labels, `Order`, `latest`, `isPinned`, `next`, and the `fileName` / `schemaUrl` / `catalogUrls` derivations.
137
+ - `CatalogEntry` — the `catalog.json` entry as a `Schema.Class`, `assemble` for both catalog modes, and the `fileMatch` hygiene lint.
138
+ - `DocumentLint` — the total structural lint (`UnresolvedRef`, `UnknownKeyword`, `DepthExceeded`, `DescriptionWithoutUrl`, …), findings as values.
139
+ - `SchemaValidator` — the validation contract: `noop` switches it off, `makeTest` / `layerTest` are the doubles; `ValidationFinding` and `SchemaValidatorError` are its values. The engine is `AjvValidator` in `@effected/schemastore-cli`.
140
+ - `DocumentDiff` — `classify` two documents as `"none"` / `"annotations"` / `"contract"`, the signal for whether a change needs a new version.
141
+ - `DriftPolicy` — the lifecycle rule the CLI applies: `published` documents are held to a tolerance (`strict` / `semantic` / `allow`), unpublished ones regenerate in place.
142
+ - `SchemaPipeline` — the emit loop: `run` / `check`, `runOne` / `checkOne`, the gate and the contract guard.
143
+ - `SchemaFile` — write-if-changed IO over core `FileSystem` / `Path`, comparing by parsed content so a formatter that owns the file's text does not churn it; `check` is the non-writing half.
144
+ - `CanonicalJson` — the deterministic serializer (insertion order, tabs, one trailing newline, typed failures) and `equals`, the one content-equality rule.
344
145
 
345
146
  ## License
346
147
 
347
- [MIT](LICENSE)
148
+ MIT
package/SchemaPipeline.js CHANGED
@@ -119,15 +119,18 @@ const gate = (target, findings, options) => {
119
119
  * write — the loop every consumer of this package was writing by hand.
120
120
  *
121
121
  * Requires `SchemaFile` and `SchemaValidator` in `R`; provide
122
- * `SchemaFile.layer` and `SchemaValidator.layer` (plus a platform
123
- * `FileSystem` / `Path`) at the edge. Findings come back as **values**, so
122
+ * `SchemaFile.layer` and an engine — `AjvValidator.layer` from
123
+ * `@effected/schemastore-cli`, which is what the `schemastore` command
124
+ * composes, or `SchemaValidator.noop` to skip validation — plus a platform
125
+ * `FileSystem` / `Path` at the edge. Findings come back as **values**, so
124
126
  * the package never chooses your log wording — but the gating decision,
125
127
  * which is the part that must not silently differ between consumers, has
126
128
  * one default and one override point.
127
129
  *
128
130
  * @example
129
131
  * ```ts
130
- * import { SchemaFile, SchemaPipeline, SchemaTarget, SchemaValidator } from "@effected/schemastore";
132
+ * import { SchemaFile, SchemaPipeline, SchemaTarget } from "@effected/schemastore";
133
+ * import { AjvValidator } from "@effected/schemastore-cli";
131
134
  * import { NodeServices } from "@effect/platform-node";
132
135
  * import { Effect, Layer, Schema } from "effect";
133
136
  *
@@ -140,7 +143,7 @@ const gate = (target, findings, options) => {
140
143
  * ];
141
144
  *
142
145
  * const program = SchemaPipeline.run(targets).pipe(
143
- * Effect.provide(Layer.mergeAll(SchemaFile.layer, SchemaValidator.layer)),
146
+ * Effect.provide(Layer.mergeAll(SchemaFile.layer, AjvValidator.layer)),
144
147
  * Effect.provide(NodeServices.layer),
145
148
  * );
146
149
  * ```
@@ -1,10 +1,6 @@
1
- import { KeywordFamilies } from "./KeywordFamilies.js";
2
1
  import { Context, Effect, Layer, Schema } from "effect";
3
- import { Ajv } from "ajv";
4
- import ajvFormats from "ajv-formats";
5
2
 
6
3
  //#region src/SchemaValidator.ts
7
- const addFormats = ajvFormats.default;
8
4
  /**
9
5
  * Indicates that the validation engine behind the {@link SchemaValidator}
10
6
  * contract failed as a *mechanism* — it could not run at all.
@@ -38,50 +34,25 @@ var ValidationFinding = class extends Schema.Class("ValidationFinding")({
38
34
  /** The JSON Schema keyword the finding is about, when the engine names one. */
39
35
  keyword: Schema.optionalKey(Schema.String)
40
36
  }) {};
41
- const collectDeclaredKeywords = (node, into, depth) => {
42
- if (depth >= 256 || typeof node !== "object" || node === null) return;
43
- if (Array.isArray(node)) {
44
- for (const element of node) collectDeclaredKeywords(element, into, depth + 1);
45
- return;
46
- }
47
- for (const [key, value] of Object.entries(node)) {
48
- if (KeywordFamilies.isDeclared(key)) into.add(key);
49
- collectDeclaredKeywords(value, into, depth + 1);
50
- }
51
- };
52
- const findingFromAjvError = (error) => ValidationFinding.make({
53
- path: error.instancePath,
54
- message: error.message ?? "schema is not valid",
55
- keyword: error.keyword
56
- });
57
37
  /** The default for an unstubbed {@link SchemaValidator.makeTest} member. */
58
38
  const notStubbed = (method) => () => Effect.die(/* @__PURE__ */ new Error(`SchemaValidator.makeTest: ${method}() was called but not stubbed — no honest default exists for a test double; pass a \`${method}\` override.`));
59
39
  /**
60
- * Real-engine JSON Schema document validation, closed by default over ajv —
61
- * the engine SchemaStore's own gate is defined in terms of.
40
+ * The JSON Schema document validation contract — the engine SchemaStore's
41
+ * own gate is defined in terms of, as a service the pipeline requires in
42
+ * `R` and never owns.
62
43
  *
63
- * {@link SchemaValidator.layer} is the shipped implementation: provide it and
64
- * validation works, with no adapter to write. The service stays an interface
65
- * so a test can swap it ({@link SchemaValidator.layerTest}) or skip it
66
- * ({@link SchemaValidator.noop}), and so a consumer standardized on a
67
- * different engine can substitute one — but writing an adapter is no longer
68
- * the price of admission. `DocumentLint` remains the owned, engine-free
69
- * structural half of the validation story, and answers questions ajv does
70
- * not (SchemaStore's own hygiene conventions).
71
- *
72
- * The shipped layer registers every declared {@link KeywordFamilies} keyword
73
- * present in the document before compiling, so ajv strict mode does not
74
- * reject the language-server families `DocumentLint` deliberately allows —
75
- * one predicate governs both verdicts. It also registers the standard
76
- * ajv-formats vocabulary (`date-time`, `date`, `time`, `duration`, `uri`,
77
- * `uri-reference`, `uri-template`, `url`, `email`, `hostname`, `ipv4`,
78
- * `ipv6`, `regex`, `uuid`, `json-pointer`, `relative-json-pointer`, …), so a
79
- * published document can say "this string is an ISO-8601 instant" with a
80
- * `format` instead of falling back to a `pattern` plus a runtime filter;
81
- * an UNKNOWN format string remains a strict-mode rejection. The plugin's
82
- * `formatMaximum` / `formatMinimum` limit keywords are deliberately NOT
83
- * registered — `DocumentLint` answers those as unknown keywords, and the
84
- * two verdicts must not drift.
44
+ * This package ships the contract and its doubles only: skip validation
45
+ * with {@link SchemaValidator.noop}, stub it with
46
+ * {@link SchemaValidator.layerTest}. The one real implementation is
47
+ * `@effected/schemastore-cli`'s `AjvValidator.layer` — ajv strict mode over
48
+ * the declared `KeywordFamilies` and the standard `ajv-formats`
49
+ * vocabulary — which the `schemastore` command composes for you and which
50
+ * that package also exports for a program that drives `SchemaPipeline`
51
+ * itself. Keeping the engine there keeps `ajv` out of every application that
52
+ * imports this package at runtime (to read a `HostedSchema`, say) and out of
53
+ * its bundle. `DocumentLint` is the owned, engine-free structural half of the
54
+ * validation story, and answers questions ajv does not (SchemaStore's own
55
+ * hygiene conventions).
85
56
  *
86
57
  * @example
87
58
  * ```ts
@@ -93,60 +64,20 @@ const notStubbed = (method) => () => Effect.die(/* @__PURE__ */ new Error(`Schem
93
64
  * return yield* validator.validate({ type: "object" });
94
65
  * });
95
66
  *
96
- * Effect.runPromise(Effect.provide(program, SchemaValidator.layer));
67
+ * // Provide the engine at the edge — the CLI does this for you:
68
+ * // Effect.provide(program, AjvValidator.layer) (from @effected/schemastore-cli)
69
+ * Effect.runPromise(Effect.provide(program, SchemaValidator.noop));
97
70
  * // => []
98
71
  * ```
99
72
  *
100
73
  * @public
101
74
  */
102
75
  var SchemaValidator = class SchemaValidator extends Context.Service()("@effected/schemastore/SchemaValidator") {
103
- /**
104
- * The shipped ajv implementation — the default a consumer provides.
105
- *
106
- * `validate` checks the document against the Draft-07 meta-schema and
107
- * then compiles it, reporting BOTH as {@link ValidationFinding} values:
108
- * meta-schema failures keep ajv's structured `instancePath` and
109
- * `keyword`, while a rejection ajv raises by *throwing* becomes a
110
- * root-pathed finding — both a strict-mode compile failure and a
111
- * declared keyword whose NAME ajv's own grammar
112
- * (`/^[a-z_$][a-z0-9_$:-]*$/i`) refuses, such as an `x-ai-*` key
113
- * carrying a dot or a space. The error channel stays reserved for the
114
- * engine failing as a mechanism.
115
- *
116
- * `strict` defaults to `true` — SchemaStore's gate. Each call builds its
117
- * own ajv instance, so documents sharing an `$id` never collide. The
118
- * standard ajv-formats vocabulary is registered on every instance, so
119
- * `format: "date-time"` (and the rest of the standard set) compiles under
120
- * strict mode instead of being rejected as an unknown format.
121
- */
122
- static layer = Layer.succeed(SchemaValidator, { validate: (document, options) => Effect.try({
123
- try: () => {
124
- const ajv = new Ajv({
125
- strict: options?.strict ?? true,
126
- allErrors: true
127
- });
128
- addFormats(ajv, { keywords: false });
129
- const declared = /* @__PURE__ */ new Set();
130
- collectDeclaredKeywords(document, declared, 0);
131
- try {
132
- for (const keyword of declared) ajv.addKeyword({ keyword });
133
- if (!ajv.validateSchema(document)) return (ajv.errors ?? []).map(findingFromAjvError);
134
- ajv.compile(document);
135
- } catch (cause) {
136
- return [ValidationFinding.make({
137
- path: "",
138
- message: cause instanceof Error ? cause.message : String(cause)
139
- })];
140
- }
141
- return [];
142
- },
143
- catch: (cause) => SchemaValidatorError.make({ cause })
144
- }) });
145
76
  /**
146
77
  * No-op: `validate` always succeeds with no findings, never consulting an
147
78
  * engine. A pure `Layer.succeed`, bound to a const so the layer memoizes
148
79
  * by reference. Use it to switch validation off deliberately — for the
149
- * real engine, provide {@link SchemaValidator.layer}.
80
+ * real engine, provide `AjvValidator.layer` from `@effected/schemastore-cli`.
150
81
  */
151
82
  static noop = Layer.succeed(SchemaValidator, { validate: () => Effect.succeed([]) });
152
83
  /**