@effected/schemastore 0.1.0 → 0.1.1
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 +246 -0
- package/package.json +1 -1
package/README.md
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
# @effected/schemastore
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@effected/schemastore)
|
|
4
|
+
[](https://opensource.org/licenses/MIT)
|
|
5
|
+
[](https://nodejs.org/)
|
|
6
|
+
[](https://www.typescriptlang.org/)
|
|
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.
|
|
9
|
+
|
|
10
|
+
> **Pre-release.** This package is part of the `@effected/*` kit, in pre-`1.0.0`
|
|
11
|
+
> development against a single pinned Effect v4 beta. Packages graduate to
|
|
12
|
+
> `1.0.0` once Effect `4.0.0` ships. To hold your own `effect` versions at
|
|
13
|
+
> exactly the ones the kit is built and tested against, install
|
|
14
|
+
> [`@effected/pnpm-plugin-effect`](https://www.npmjs.com/package/@effected/pnpm-plugin-effect).
|
|
15
|
+
>
|
|
16
|
+
> **Stability: unstable.** This package's API surface is not yet considered
|
|
17
|
+
> complete and may change across `0.x` releases. Pin an exact version — even a
|
|
18
|
+
> package marked *stable* before `1.0.0` can introduce a breaking change by
|
|
19
|
+
> accident, and an exact pin turns that into a type-check error rather than a
|
|
20
|
+
> runtime surprise. Full policy: [release strategy](https://github.com/spencerbeggs/effected#release-strategy).
|
|
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 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
|
+
|
|
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.
|
|
27
|
+
|
|
28
|
+
## Install
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
npm install @effected/schemastore effect
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pnpm add @effected/schemastore effect
|
|
36
|
+
```
|
|
37
|
+
|
|
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. `@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`.
|
|
43
|
+
|
|
44
|
+
## Quick start
|
|
45
|
+
|
|
46
|
+
Turn an Effect Schema into a publication-ready document:
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
import { StoreDocument } from "@effected/schemastore";
|
|
50
|
+
import { Effect, Schema } from "effect";
|
|
51
|
+
|
|
52
|
+
const Config = Schema.Struct({ name: Schema.String });
|
|
53
|
+
|
|
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": false
|
|
75
|
+
// }
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
`fromSchema` runs the whole pipeline — 2020-12 generation, Draft-07 lowering, the `$ref` rewrite and the annotation re-graft — so every `$ref` in a built document already resolves against its `$defs` pool. `toJson()` is the flat publication shape (`$defs` omitted when empty) and `serializeResult` routes through the owned canonical serializer, tab-indented with a single trailing newline. If core cannot convert a schema, the failure is typed as `SchemaConversionError` and carries the `$id` and the structured cause.
|
|
79
|
+
|
|
80
|
+
## Two rules to read first
|
|
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.
|
|
86
|
+
|
|
87
|
+
## Carrying language-server annotations
|
|
88
|
+
|
|
89
|
+
SchemaStore documents lean on non-standard keywords the editors read: the vscode-json-languageservice set (`markdownDescription`, `defaultSnippets`, `enumDescriptions`, `markdownEnumDescriptions`, `allowTrailingCommas`), taplo's `x-taplo` keys, tombi's `x-tombi-*` and IntelliJ's `x-intellij-*`. Effect Schema annotations accept arbitrary string keys, so `Schema.String.annotate({ "x-taplo": { ... } })` type-checks with no module augmentation — but core's Draft-07 lowering copies a fixed keyword subset and would drop them. `StoreDocument.fromSchema` re-grafts the declared families onto the lowered document with a parallel walk (`AnnotationCarriers`), so the annotation you wrote is the keyword that ships:
|
|
90
|
+
|
|
91
|
+
```ts
|
|
92
|
+
import { StoreDocument } from "@effected/schemastore";
|
|
93
|
+
import { Effect, Schema } from "effect";
|
|
94
|
+
|
|
95
|
+
const Config = Schema.Struct({
|
|
96
|
+
name: Schema.String.annotate({ "x-taplo": { docs: { main: "The display name." } } }),
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const program = Effect.gen(function* () {
|
|
100
|
+
const document = yield* StoreDocument.fromSchema(Config, {
|
|
101
|
+
$id: "https://example.com/config.schema.json",
|
|
102
|
+
});
|
|
103
|
+
return document.root.properties;
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
console.log(Effect.runSync(program));
|
|
107
|
+
// => { name: { type: "string", "x-taplo": { docs: { main: "The display name." } } } }
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
The declared families are always admitted — `KeywordFamilies` is the one registry, consumed by both the carriers and the lint, so the two cannot drift on what counts as declared. A caller-supplied `includeAnnotationKey` predicate is consulted in addition, but know the boundary: keys it admits outside the declared families reach the Draft 2020-12 document and are still dropped by the lowering.
|
|
111
|
+
|
|
112
|
+
## Catalog entries and versioning
|
|
113
|
+
|
|
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 rejected — not 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.
|
|
115
|
+
|
|
116
|
+
`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
|
+
|
|
118
|
+
```ts
|
|
119
|
+
import { CatalogEntry, SchemaVersioning } from "@effected/schemastore";
|
|
120
|
+
import { Effect } from "effect";
|
|
121
|
+
|
|
122
|
+
const program = Effect.gen(function* () {
|
|
123
|
+
const versions = yield* Effect.forEach(["1.9", "1.10"], SchemaVersioning.parse);
|
|
124
|
+
return CatalogEntry.assemble({
|
|
125
|
+
name: "My Tool",
|
|
126
|
+
description: "Configuration for My Tool.",
|
|
127
|
+
fileMatch: ["mytool.config.json"],
|
|
128
|
+
baseUrl: "https://example.com/schemas",
|
|
129
|
+
fileBaseName: "mytool",
|
|
130
|
+
versions,
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
console.log(Effect.runSync(program).url);
|
|
135
|
+
// => "https://example.com/schemas/mytool-1.10.json"
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
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:
|
|
139
|
+
|
|
140
|
+
```ts
|
|
141
|
+
import { CatalogEntry } from "@effected/schemastore";
|
|
142
|
+
|
|
143
|
+
const findings = CatalogEntry.lintFileMatch(["config.toml", "**/{a,b}.json"]);
|
|
144
|
+
|
|
145
|
+
console.log(findings.map((finding) => finding.check));
|
|
146
|
+
// => ["GenericFileMatch", "ComplexFileMatch"]
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
`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.
|
|
150
|
+
|
|
151
|
+
## Linting documents
|
|
152
|
+
|
|
153
|
+
`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.
|
|
154
|
+
|
|
155
|
+
| Check | Severity | Fires when |
|
|
156
|
+
| ----- | -------- | ---------- |
|
|
157
|
+
| `UnresolvedRef` | warning | a `$ref` does not resolve against the `$defs` pool — including a `#/definitions/...` pointer that survived where it should not |
|
|
158
|
+
| `UnknownKeyword` | warning | a keyword sits outside Draft-07 plus the declared non-standard families, which ajv strict mode would reject |
|
|
159
|
+
| `DescriptionWithoutUrl` | advisory | the root description's last line is not a documentation URL (SchemaStore's description convention) |
|
|
160
|
+
| `DepthExceeded` | warning | nesting exceeds the depth cap; the walk stops there instead of failing |
|
|
161
|
+
|
|
162
|
+
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.
|
|
163
|
+
|
|
164
|
+
## Real-engine validation
|
|
165
|
+
|
|
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:
|
|
167
|
+
|
|
168
|
+
```ts
|
|
169
|
+
import { SchemaValidator, StoreDocument, ValidationFinding } from "@effected/schemastore";
|
|
170
|
+
import Ajv from "ajv";
|
|
171
|
+
import { Effect, Layer, Schema } from "effect";
|
|
172
|
+
|
|
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
|
+
});
|
|
185
|
+
|
|
186
|
+
const program = Effect.gen(function* () {
|
|
187
|
+
const validator = yield* SchemaValidator;
|
|
188
|
+
const document = yield* StoreDocument.fromSchema(Schema.Struct({ name: Schema.String }), {
|
|
189
|
+
$id: "https://example.com/config.schema.json",
|
|
190
|
+
});
|
|
191
|
+
return yield* validator.validate(document.toJson());
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
Effect.runPromise(Effect.provide(program, AjvValidator)).then(console.log);
|
|
195
|
+
// [] when the document compiles clean; the engine's findings otherwise
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
`validate` takes the flat serialized record — `StoreDocument.toJson()`'s shape — so the seam stays engine-shaped and decoupled from this package's classes.
|
|
199
|
+
|
|
200
|
+
## Writing schema files
|
|
201
|
+
|
|
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:
|
|
203
|
+
|
|
204
|
+
```ts
|
|
205
|
+
import { SchemaFile, StoreDocument } from "@effected/schemastore";
|
|
206
|
+
import { NodeFileSystem, NodePath } from "@effect/platform-node";
|
|
207
|
+
import { Effect, Layer, Schema } from "effect";
|
|
208
|
+
|
|
209
|
+
const program = Effect.gen(function* () {
|
|
210
|
+
const files = yield* SchemaFile;
|
|
211
|
+
const document = yield* StoreDocument.fromSchema(Schema.Struct({ name: Schema.String }), {
|
|
212
|
+
$id: "https://example.com/config.schema.json",
|
|
213
|
+
});
|
|
214
|
+
const first = yield* files.write("schemas/config.schema.json", document);
|
|
215
|
+
const second = yield* files.write("schemas/config.schema.json", document);
|
|
216
|
+
return [first, second] 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
|
+
// => ["written", "unchanged"]
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
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
|
+
|
|
228
|
+
## Canonical JSON
|
|
229
|
+
|
|
230
|
+
`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
|
+
|
|
232
|
+
## Features
|
|
233
|
+
|
|
234
|
+
- `StoreDocument` — the assembly pipeline: `fromSchema` / `fromSchemaResult`, the flat `toJson()` publication shape, `serializeResult()`, the `DRAFT_07_META_SCHEMA` constant and `SchemaConversionError`.
|
|
235
|
+
- `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`).
|
|
238
|
+
- `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.
|
|
242
|
+
- `CanonicalJson` — the deterministic serializer with typed failures (`NonJsonValueError`, `JsonDepthExceededError`).
|
|
243
|
+
|
|
244
|
+
## License
|
|
245
|
+
|
|
246
|
+
[MIT](LICENSE)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@effected/schemastore",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Build, version and lint SchemaStore-shaped Draft-07 JSON Schema documents from Effect Schema sources: document assembly, catalog entries, versioned catalogs and canonical JSON serialization.",
|
|
6
6
|
"keywords": [
|