@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.
- package/DocumentDiff.js +178 -0
- package/README.md +86 -38
- package/SchemaFile.js +53 -15
- package/SchemaPipeline.js +200 -0
- package/SchemaTarget.js +8 -8
- package/SchemaValidator.js +73 -14
- package/SchemaVersioning.js +34 -43
- package/StoreDocument.js +30 -0
- package/index.d.ts +501 -64
- package/index.js +4 -2
- package/package.json +4 -3
package/DocumentDiff.js
ADDED
|
@@ -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
|
[](https://nodejs.org/)
|
|
6
6
|
[](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
|
|
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
|
|
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`
|
|
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
|
-
##
|
|
80
|
+
## Annotate at the definition site
|
|
81
81
|
|
|
82
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
-
// => [
|
|
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` —
|
|
237
|
-
- `CatalogEntry` — the `catalog.json` entry as a `Schema.Class`, `assemble
|
|
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` —
|
|
240
|
-
- `
|
|
241
|
-
- `
|
|
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
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
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
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
|
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
|
});
|