@effected/schemastore 0.1.1 → 0.2.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.
@@ -0,0 +1,178 @@
1
+ import { KeywordFamilies } from "./KeywordFamilies.js";
2
+
3
+ //#region src/DocumentDiff.ts
4
+ const DOCUMENTATION_KEYWORDS = /* @__PURE__ */ new Set([
5
+ "title",
6
+ "description",
7
+ "$comment"
8
+ ]);
9
+ const SCHEMA_MAP_KEYWORDS = /* @__PURE__ */ new Set([
10
+ "properties",
11
+ "patternProperties",
12
+ "$defs",
13
+ "definitions"
14
+ ]);
15
+ const SCHEMA_ARRAY_KEYWORDS = /* @__PURE__ */ new Set([
16
+ "allOf",
17
+ "anyOf",
18
+ "oneOf"
19
+ ]);
20
+ const SCHEMA_KEYWORDS = /* @__PURE__ */ new Set([
21
+ "additionalItems",
22
+ "additionalProperties",
23
+ "propertyNames",
24
+ "contains",
25
+ "if",
26
+ "then",
27
+ "else",
28
+ "not"
29
+ ]);
30
+ const isSchemaObject = (node) => typeof node === "object" && node !== null && !Array.isArray(node);
31
+ const VALUE_COMPARISON_STACK_GUARD = 256 * 8;
32
+ const deepEqual = (a, b, depth) => {
33
+ if (a === b) return true;
34
+ if (depth >= VALUE_COMPARISON_STACK_GUARD) return false;
35
+ if (Array.isArray(a) || Array.isArray(b)) {
36
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
37
+ return a.every((element, index) => deepEqual(element, b[index], depth + 1));
38
+ }
39
+ if (!isSchemaObject(a) || !isSchemaObject(b)) return false;
40
+ const aKeys = Object.keys(a);
41
+ if (aKeys.length !== Object.keys(b).length) return false;
42
+ return aKeys.every((key) => Object.hasOwn(b, key) && deepEqual(a[key], b[key], depth + 1));
43
+ };
44
+ const worst = (left, right) => {
45
+ if (left === "contract" || right === "contract") return "contract";
46
+ if (left === "annotations" || right === "annotations") return "annotations";
47
+ return "none";
48
+ };
49
+ const isAnnotationKey = (key) => DOCUMENTATION_KEYWORDS.has(key) || KeywordFamilies.isDeclared(key);
50
+ const compareSchema = (a, b, depth) => {
51
+ if (a === b) return "none";
52
+ if (depth >= 256) return deepEqual(a, b, 0) ? "none" : "contract";
53
+ if (!isSchemaObject(a) || !isSchemaObject(b)) return deepEqual(a, b, depth) ? "none" : "contract";
54
+ let change = "none";
55
+ const keys = /* @__PURE__ */ new Set([...Object.keys(a), ...Object.keys(b)]);
56
+ for (const key of keys) {
57
+ if (!(Object.hasOwn(a, key) && Object.hasOwn(b, key))) {
58
+ change = worst(change, isAnnotationKey(key) ? "annotations" : "contract");
59
+ if (change === "contract") return "contract";
60
+ continue;
61
+ }
62
+ const left = a[key];
63
+ const right = b[key];
64
+ if (isAnnotationKey(key)) {
65
+ change = worst(change, deepEqual(left, right, depth) ? "none" : "annotations");
66
+ continue;
67
+ }
68
+ if (SCHEMA_MAP_KEYWORDS.has(key)) change = worst(change, compareSchemaMap(left, right, depth + 1));
69
+ else if (SCHEMA_ARRAY_KEYWORDS.has(key)) change = worst(change, compareSchemaArray(left, right, depth + 1));
70
+ else if (SCHEMA_KEYWORDS.has(key)) change = worst(change, compareSchema(left, right, depth + 1));
71
+ else if (key === "items") change = worst(change, Array.isArray(left) || Array.isArray(right) ? compareSchemaArray(left, right, depth + 1) : compareSchema(left, right, depth + 1));
72
+ else if (key === "dependencies") change = worst(change, compareDependencies(left, right, depth + 1));
73
+ else change = worst(change, deepEqual(left, right, depth) ? "none" : "contract");
74
+ if (change === "contract") return "contract";
75
+ }
76
+ return change;
77
+ };
78
+ const compareSchemaMap = (a, b, depth) => {
79
+ if (!isSchemaObject(a) || !isSchemaObject(b)) return deepEqual(a, b, depth) ? "none" : "contract";
80
+ let change = "none";
81
+ const names = /* @__PURE__ */ new Set([...Object.keys(a), ...Object.keys(b)]);
82
+ for (const name of names) {
83
+ if (!Object.hasOwn(a, name) || !Object.hasOwn(b, name)) return "contract";
84
+ change = worst(change, compareSchema(a[name], b[name], depth + 1));
85
+ if (change === "contract") return "contract";
86
+ }
87
+ return change;
88
+ };
89
+ const compareSchemaArray = (a, b, depth) => {
90
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return "contract";
91
+ let change = "none";
92
+ for (const [index, element] of a.entries()) {
93
+ change = worst(change, compareSchema(element, b[index], depth + 1));
94
+ if (change === "contract") return "contract";
95
+ }
96
+ return change;
97
+ };
98
+ const compareDependencies = (a, b, depth) => {
99
+ if (!isSchemaObject(a) || !isSchemaObject(b)) return deepEqual(a, b, depth) ? "none" : "contract";
100
+ let change = "none";
101
+ const names = /* @__PURE__ */ new Set([...Object.keys(a), ...Object.keys(b)]);
102
+ for (const name of names) {
103
+ if (!Object.hasOwn(a, name) || !Object.hasOwn(b, name)) return "contract";
104
+ const left = a[name];
105
+ const right = b[name];
106
+ change = worst(change, Array.isArray(left) || Array.isArray(right) ? deepEqual(left, right, depth) ? "none" : "contract" : compareSchema(left, right, depth + 1));
107
+ if (change === "contract") return "contract";
108
+ }
109
+ return change;
110
+ };
111
+ /**
112
+ * Classifies the difference between two emitted schema documents by
113
+ * meaning: identical, documentation-only, or a change to the validation
114
+ * contract.
115
+ *
116
+ * The walk is keyword-position aware in exactly the way {@link DocumentLint}'s
117
+ * is — a property NAMED `description` inside `properties` is data, not an
118
+ * annotation — and object key order is never a difference, so a document
119
+ * reformatted (or key-sorted) by another tool still classifies as `"none"`.
120
+ *
121
+ * Total: hostile nesting past the package's depth cap stops the structural
122
+ * walk and degrades to a whole-subtree comparison reported as `"contract"`
123
+ * when unequal, never a throw.
124
+ *
125
+ * @example
126
+ * ```ts
127
+ * import { DocumentDiff } from "@effected/schemastore";
128
+ *
129
+ * const before = { type: "object", properties: { name: { type: "string", description: "A" } } };
130
+ * const after = { type: "object", properties: { name: { type: "string", description: "B" } } };
131
+ *
132
+ * console.log(DocumentDiff.classify(before, after));
133
+ * // => "annotations" (republish transparently; no new version needed)
134
+ *
135
+ * console.log(DocumentDiff.classify(before, { ...before, required: ["name"] }));
136
+ * // => "contract" (documents valid before may be invalid now)
137
+ * ```
138
+ *
139
+ * @public
140
+ */
141
+ var DocumentDiff = class {
142
+ constructor() {}
143
+ /**
144
+ * Classify the difference between two emitted document values (the
145
+ * `toJson()` publication shape, or anything parsed from a written
146
+ * schema file). Both sides are plain JSON values, so this compares an
147
+ * on-disk document against a freshly built one without either being a
148
+ * {@link StoreDocument}.
149
+ */
150
+ static classify(existing, next) {
151
+ return compareSchema(existing, next, 0);
152
+ }
153
+ /**
154
+ * Whether a classification means "nothing changed" — `true` only for
155
+ * `"none"`.
156
+ *
157
+ * Exists so the clean case is not a string literal every consumer
158
+ * spells for itself. `"created"` is deliberately NOT clean: a file that
159
+ * did not exist is a change.
160
+ */
161
+ static isClean(change) {
162
+ return change === "none";
163
+ }
164
+ /**
165
+ * Whether `key`, standing at a schema position, is a documentation
166
+ * keyword: `title`, `description`, `$comment`, or any declared
167
+ * non-standard language-server family ({@link KeywordFamilies}).
168
+ *
169
+ * `default`, `examples`, `readOnly` and `writeOnly` are deliberately
170
+ * NOT documentation — consumers act on them.
171
+ */
172
+ static isAnnotationKeyword(key) {
173
+ return isAnnotationKey(key);
174
+ }
175
+ };
176
+
177
+ //#endregion
178
+ export { DocumentDiff };
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 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 and 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, canonical JSON text, write-if-changed file IO and a validation contract seam a consumer closes with a real engine at the edge.
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.
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 beta. Packages graduate to
@@ -23,7 +23,7 @@ Build, version and lint SchemaStore-shaped Draft-07 JSON Schema documents from E
23
23
 
24
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.
25
25
 
26
- The scope is deliberately narrow. There is no schema construction here, no ref resolution beyond the document's own `$defs` pool, no dialect conversion and no JSON Schema engine anywhere in the runtime graph — core's `JsonSchema` owns the pipeline, ajv stays at the consumer's edge behind a contract, and this package owns the SchemaStore shape in between.
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
 
28
28
  ## Install
29
29
 
@@ -39,7 +39,7 @@ Requires Node.js >=24.11.0.
39
39
 
40
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
41
 
42
- `effect` v4 is the only peer dependency. `@effected/semver` rides along as a regular dependency — it does the version ordering inside `SchemaVersioning`, and no `SemVer` type surfaces in the public API. 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`.
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`.
43
43
 
44
44
  ## Quick start
45
45
 
@@ -77,12 +77,9 @@ console.log(Effect.runSync(program));
77
77
 
78
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.
79
79
 
80
- ## Two rules to read first
80
+ ## Annotate at the definition site
81
81
 
82
- Two constraints bite consumers who do not know them, so they come before the feature tour:
83
-
84
- - **Annotate at the definition site.** 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
- - **Read version ordering from labels, never from key position.** A versioned catalog's `versions` map is a JSON object, and JavaScript enumerates integer-like keys first: a bare-major label like `"2"` serializes ahead of every dotted label regardless of insertion order. `SchemaVersioning.Order` and `SchemaVersioning.latest` order by the labels themselves; key position promises nothing.
82
+ 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.
86
83
 
87
84
  ## Carrying language-server annotations
88
85
 
@@ -111,7 +108,7 @@ The declared families are always admitted — `KeywordFamilies` is the one regis
111
108
 
112
109
  ## Catalog entries and versioning
113
110
 
114
- `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 SchemaStore's own label grammar — `major[.minor[.patch]][-prerelease]`, leading zeros rejectednot strict SemVer: `1.2` is a valid catalog label that a SemVer parser rejects. Ordering pads labels to full SemVer internally, so `1.10` sorts above `1.9` numerically and the label round-trips verbatim.
111
+ `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.
115
112
 
116
113
  `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.
117
114
 
@@ -120,7 +117,7 @@ import { CatalogEntry, SchemaVersioning } from "@effected/schemastore";
120
117
  import { Effect } from "effect";
121
118
 
122
119
  const program = Effect.gen(function* () {
123
- const versions = yield* Effect.forEach(["1.9", "1.10"], SchemaVersioning.parse);
120
+ const versions = yield* Effect.forEach(["1.9.0", "1.10.0"], SchemaVersioning.parse);
124
121
  return CatalogEntry.assemble({
125
122
  name: "My Tool",
126
123
  description: "Configuration for My Tool.",
@@ -132,7 +129,7 @@ const program = Effect.gen(function* () {
132
129
  });
133
130
 
134
131
  console.log(Effect.runSync(program).url);
135
- // => "https://example.com/schemas/mytool-1.10.json"
132
+ // => "https://example.com/schemas/mytool-1.10.0.json"
136
133
  ```
137
134
 
138
135
  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:
@@ -163,25 +160,13 @@ The keyword walk is position-aware: a *property* named `unevaluatedProperties` i
163
160
 
164
161
  ## Real-engine validation
165
162
 
166
- SchemaStore's own gate is ajv strict mode, and `SchemaValidator` is the seam that reaches it without ajv ever entering this package's dependency graph. The contract's channel convention: findings are values — an ajv strict-mode compile failure is a report, not an error and the error channel is reserved for the engine failing as a mechanism (`SchemaValidatorError`). The package ships `noop` (validates nothing) and `makeTest` / `layerTest` (unstubbed members die naming themselves); the consumer closes the seam at the application edge. The `ajv` import below (like `@effect/platform-node` in the file-writing example) is the consumer's own dependency install it to run the example; nothing here depends on it:
163
+ 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.
167
164
 
168
- ```ts
169
- import { SchemaValidator, StoreDocument, ValidationFinding } from "@effected/schemastore";
170
- import Ajv from "ajv";
171
- import { Effect, Layer, Schema } from "effect";
165
+ 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.
172
166
 
173
- const AjvValidator = Layer.succeed(SchemaValidator, {
174
- validate: (document, options) =>
175
- Effect.sync(() => {
176
- const ajv = new Ajv({ strict: options?.strict !== false, allErrors: true });
177
- try {
178
- ajv.compile(document);
179
- return [];
180
- } catch (error) {
181
- return [ValidationFinding.make({ path: "", message: String(error) })];
182
- }
183
- }),
184
- });
167
+ ```ts
168
+ import { SchemaValidator, StoreDocument } from "@effected/schemastore";
169
+ import { Effect, Schema } from "effect";
185
170
 
186
171
  const program = Effect.gen(function* () {
187
172
  const validator = yield* SchemaValidator;
@@ -191,15 +176,21 @@ const program = Effect.gen(function* () {
191
176
  return yield* validator.validate(document.toJson());
192
177
  });
193
178
 
194
- Effect.runPromise(Effect.provide(program, AjvValidator)).then(console.log);
179
+ Effect.runPromise(Effect.provide(program, SchemaValidator.layer)).then(console.log);
195
180
  // [] when the document compiles clean; the engine's findings otherwise
196
181
  ```
197
182
 
183
+ 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.
184
+
198
185
  `validate` takes the flat serialized record — `StoreDocument.toJson()`'s shape — so the seam stays engine-shaped and decoupled from this package's classes.
199
186
 
200
187
  ## Writing schema files
201
188
 
202
- `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 outcome is a value, so a generator committed to a repo does not churn mtimes, and a CI drift check is `read` plus compare:
189
+ `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.
190
+
191
+ 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.
192
+
193
+ `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:
203
194
 
204
195
  ```ts
205
196
  import { SchemaFile, StoreDocument } from "@effected/schemastore";
@@ -213,32 +204,89 @@ const program = Effect.gen(function* () {
213
204
  });
214
205
  const first = yield* files.write("schemas/config.schema.json", document);
215
206
  const second = yield* files.write("schemas/config.schema.json", document);
216
- return [first, second] as const;
207
+ const drift = yield* files.check("schemas/config.schema.json", document);
208
+ return [first, second, drift] as const;
217
209
  }).pipe(
218
210
  Effect.provide(SchemaFile.layer),
219
211
  Effect.provide(Layer.mergeAll(NodeFileSystem.layer, NodePath.layer)),
220
212
  );
221
213
 
222
214
  Effect.runPromise(program).then(console.log);
223
- // => ["written", "unchanged"]
215
+ // => [
216
+ // { outcome: "written", change: "created" },
217
+ // { outcome: "unchanged", change: "none" },
218
+ // { wouldWrite: false, change: "none" },
219
+ // ]
224
220
  ```
225
221
 
222
+ `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.
223
+
226
224
  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.
227
225
 
226
+ ## The emit pipeline
227
+
228
+ `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.
229
+
230
+ ```ts
231
+ import { SchemaFile, SchemaPipeline, SchemaTarget, SchemaValidator } from "@effected/schemastore";
232
+ import { NodeServices } from "@effect/platform-node";
233
+ import { Effect, Layer, Schema } from "effect";
234
+
235
+ const targets = [
236
+ SchemaTarget.make({
237
+ schema: Schema.Struct({ name: Schema.String }),
238
+ $id: "https://example.com/config.schema.json",
239
+ path: "schemas/config.schema.json",
240
+ }),
241
+ ];
242
+
243
+ const program = SchemaPipeline.run(targets).pipe(
244
+ Effect.provide(Layer.mergeAll(SchemaFile.layer, SchemaValidator.layer)),
245
+ Effect.provide(NodeServices.layer),
246
+ );
247
+
248
+ Effect.runPromise(program).then(console.log);
249
+ // => [{ $id, path, outcome: "written", change: "created", findings: [] }]
250
+ ```
251
+
252
+ 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`.
253
+
254
+ 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.
255
+
256
+ ```ts
257
+ SchemaPipeline.run(targets, { blocking: (finding) => finding.source === "validator" });
258
+ ```
259
+
260
+ 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.
261
+
262
+ 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.
263
+
264
+ `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.
265
+
266
+ `runOne` and `checkOne` take a single target and answer its one result directly, so a one-target caller need not prove element zero exists.
267
+
268
+ ## Comparing two documents
269
+
270
+ `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.
271
+
272
+ 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.
273
+
228
274
  ## Canonical JSON
229
275
 
230
276
  `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.
231
277
 
232
278
  ## Features
233
279
 
234
- - `StoreDocument` — the assembly pipeline: `fromSchema` / `fromSchemaResult`, the flat `toJson()` publication shape, `serializeResult()`, the `DRAFT_07_META_SCHEMA` constant and `SchemaConversionError`.
280
+ - `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`.
235
281
  - `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.
236
- - `SchemaVersioning` / `SchemaVersion` — the store-native label grammar with `parseResult` / `parse`, the `Order` instance and `latest`, plus `fileName`, `schemaUrl` and `catalogUrls` deriving both catalog modes.
237
- - `CatalogEntry` — the `catalog.json` entry as a `Schema.Class`, `assemble`, and the `fileMatch` hygiene lint (`CatalogLintFinding`).
282
+ - `SchemaVersioning` / `SchemaVersion` — full-SemVer version labels with `parseResult` / `parse`, the `Order` instance and `latest`, plus `fileName`, `schemaUrl` and `catalogUrls` deriving both catalog modes.
283
+ - `CatalogEntry` — the `catalog.json` entry as a `Schema.Class`, `assemble` and the `fileMatch` hygiene lint (`CatalogLintFinding`).
238
284
  - `DocumentLint` — the total structural lint returning `DocumentLintFinding` values, never an error.
239
- - `SchemaValidator` — the real-engine contract seam: `ValidationFinding`, `SchemaValidatorError`, `noop` and the `makeTest` / `layerTest` doubles.
240
- - `SchemaFile` — write-if-changed IO over core `FileSystem` / `Path`, answering `"written" | "unchanged"` as a value.
241
- - `SchemaTarget` — the target manifest vocabulary: schema, `$id`, name, destination path and optional version.
285
+ - `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.
286
+ - `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.
287
+ - `SchemaPipeline` — the emit loop over a target manifest: `run` and `check`, the single-target `runOne` and `checkOne`, `PipelineFinding`, `SchemaGateError` and an overridable gating predicate.
288
+ - `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`.
289
+ - `SchemaTarget` — the target manifest vocabulary: schema, `$id`, destination path, an optional name and an optional version that requires one.
242
290
  - `CanonicalJson` — the deterministic serializer with typed failures (`NonJsonValueError`, `JsonDepthExceededError`).
243
291
 
244
292
  ## License
package/SchemaFile.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { DocumentDiff } from "./DocumentDiff.js";
1
2
  import { Context, Effect, FileSystem, Layer, Path, Schema } from "effect";
2
3
 
3
4
  //#region src/SchemaFile.ts
@@ -53,10 +54,14 @@ var SchemaFileWriteError = class extends Schema.TaggedErrorClass()("SchemaFileWr
53
54
  * services; provide `@effect/platform-node`'s `NodeFileSystem` / `NodePath`
54
55
  * (or a bun equivalent) at the application boundary.
55
56
  *
56
- * `write` is write-if-changed: serialization goes through the owned
57
- * `CanonicalJson` (equal documents serialize to equal bytes), so an
58
- * unchanged document never touches the file a generator committed to a
59
- * repo does not churn mtimes, and its CI drift check is `read` + compare.
57
+ * `write` is write-if-changed, and by default compares **content**: an
58
+ * unchanged document never touches the file even if another tool has
59
+ * reformatted it, so a generator committed to a repo whose pre-commit hook
60
+ * formats JSON does not churn on every run. It also reports what the
61
+ * difference meant — `"annotations"` (prose only, replaces its predecessor
62
+ * transparently) versus `"contract"` (an assertion moved, so a new
63
+ * `SchemaVersioning` version is warranted). `check` makes the same
64
+ * comparison without writing, which is what a CI drift job wants.
60
65
  *
61
66
  * @example
62
67
  * ```ts
@@ -83,19 +88,44 @@ var SchemaFile = class SchemaFile extends Context.Service()("@effected/schemasto
83
88
  static make = Effect.gen(function* () {
84
89
  const fs = yield* FileSystem.FileSystem;
85
90
  const path = yield* Path.Path;
91
+ const read = Effect.fn("SchemaFile.read")(function* (target) {
92
+ return yield* fs.readFileString(target).pipe(Effect.mapError((cause) => cause.reason._tag === "NotFound" ? SchemaFileNotFoundError.make({ path: target }) : SchemaFileReadError.make({
93
+ path: target,
94
+ cause
95
+ })));
96
+ });
97
+ const readForCompare = (target) => fs.readFileString(target).pipe(Effect.catch((cause) => cause.reason._tag === "NotFound" ? Effect.succeed(void 0) : Effect.fail(SchemaFileReadError.make({
98
+ path: target,
99
+ cause
100
+ }))));
101
+ const classify = (existing, text) => {
102
+ try {
103
+ return DocumentDiff.classify(JSON.parse(existing), JSON.parse(text));
104
+ } catch {
105
+ return "contract";
106
+ }
107
+ };
108
+ const compare = (existing, text, options) => {
109
+ if (existing === void 0) return {
110
+ wouldWrite: true,
111
+ change: "created"
112
+ };
113
+ const change = classify(existing, text);
114
+ return {
115
+ wouldWrite: options?.compare === "bytes" ? existing !== text : change !== "none",
116
+ change
117
+ };
118
+ };
86
119
  return {
87
- read: Effect.fn("SchemaFile.read")(function* (target) {
88
- return yield* fs.readFileString(target).pipe(Effect.mapError((cause) => cause.reason._tag === "NotFound" ? SchemaFileNotFoundError.make({ path: target }) : SchemaFileReadError.make({
89
- path: target,
90
- cause
91
- })));
92
- }),
120
+ read,
93
121
  write: Effect.fn("SchemaFile.write")(function* (target, document, options) {
94
122
  const text = yield* Effect.fromResult(document.serializeResult(options));
95
- if ((yield* fs.readFileString(target).pipe(Effect.catch((cause) => cause.reason._tag === "NotFound" ? Effect.succeed(void 0) : Effect.fail(SchemaFileReadError.make({
96
- path: target,
97
- cause
98
- }))))) === text) return "unchanged";
123
+ const existing = yield* readForCompare(target);
124
+ const { wouldWrite, change } = compare(existing, text, options);
125
+ if (!wouldWrite) return {
126
+ outcome: "unchanged",
127
+ change
128
+ };
99
129
  yield* fs.makeDirectory(path.dirname(target), { recursive: true }).pipe(Effect.mapError((cause) => SchemaFileWriteError.make({
100
130
  path: target,
101
131
  cause
@@ -104,7 +134,15 @@ var SchemaFile = class SchemaFile extends Context.Service()("@effected/schemasto
104
134
  path: target,
105
135
  cause
106
136
  })));
107
- return "written";
137
+ return {
138
+ outcome: "written",
139
+ change
140
+ };
141
+ }),
142
+ check: Effect.fn("SchemaFile.check")(function* (target, document, options) {
143
+ const text = yield* Effect.fromResult(document.serializeResult(options));
144
+ const existing = yield* readForCompare(target);
145
+ return compare(existing, text, options);
108
146
  })
109
147
  };
110
148
  });