@effected/schemastore 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/DocumentLint.js +2 -2
- package/KeywordFamilies.js +47 -7
- package/README.md +33 -6
- package/SchemaPipeline.js +91 -6
- package/SchemaValidator.js +8 -5
- package/SchemaVersioning.js +56 -0
- package/index.d.ts +632 -427
- package/index.js +2 -2
- package/package.json +3 -3
- package/tsdoc-metadata.json +1 -1
package/DocumentLint.js
CHANGED
|
@@ -107,7 +107,7 @@ const lintSchema = (node, path, depth, context) => {
|
|
|
107
107
|
check: "UnknownKeyword",
|
|
108
108
|
severity: "warning",
|
|
109
109
|
path: keyPath,
|
|
110
|
-
message: `"${key}" is not a Draft-07 keyword or a declared non-standard family (x-taplo*, x-tombi-*, x-intellij-*, vscode); ajv strict mode rejects it`
|
|
110
|
+
message: `"${key}" is not a Draft-07 keyword or a declared non-standard family (x-taplo*, x-tombi-*, x-intellij-*, x-ai-*, vscode); ajv strict mode rejects it`
|
|
111
111
|
}));
|
|
112
112
|
continue;
|
|
113
113
|
}
|
|
@@ -160,7 +160,7 @@ const lintSchema = (node, path, depth, context) => {
|
|
|
160
160
|
* `#/definitions/...` pointer, is a warning).
|
|
161
161
|
* - `UnknownKeyword` — no keyword outside Draft-07 plus the declared
|
|
162
162
|
* non-standard families ({@link KeywordFamilies}: `x-taplo*`, `x-tombi-*`,
|
|
163
|
-
* `x-intellij-*` and the vscode set), which ajv strict mode would reject.
|
|
163
|
+
* `x-intellij-*`, `x-ai-*` and the vscode set), which ajv strict mode would reject.
|
|
164
164
|
* - `DescriptionWithoutUrl` — advisory: SchemaStore's description
|
|
165
165
|
* convention ends the root description with a docs URL line.
|
|
166
166
|
*
|
package/KeywordFamilies.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
//#region src/KeywordFamilies.ts
|
|
2
2
|
/**
|
|
3
|
-
* The one owner of the declared non-standard keyword families
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
3
|
+
* The one owner of the declared non-standard keyword families, in two
|
|
4
|
+
* groups.
|
|
5
|
+
*
|
|
6
|
+
* **Upstream language-server families** — mirrored from SchemaStore's
|
|
7
|
+
* CONTRIBUTING, the keyword sets legitimately consumed by editor
|
|
8
|
+
* toolchains that ajv strict mode would otherwise reject:
|
|
7
9
|
*
|
|
8
10
|
* - **vscode-json-languageservice** (exact names): `allowTrailingCommas`,
|
|
9
11
|
* `defaultSnippets`, `enumDescriptions`, `markdownDescription`,
|
|
@@ -16,6 +18,43 @@
|
|
|
16
18
|
* - **IntelliJ**: the `x-intellij-` prefix (`x-intellij-language-injection`,
|
|
17
19
|
* `x-intellij-html-description`, `x-intellij-enum-metadata`).
|
|
18
20
|
*
|
|
21
|
+
* **The house machine-annotation family** — `x-ai-` (WITH the trailing
|
|
22
|
+
* dash; bare `x-ai` and a look-alike prefix like `x-aida-foo` are NOT
|
|
23
|
+
* declared), owned by this package rather than mirrored from anywhere:
|
|
24
|
+
*
|
|
25
|
+
* - It is a NAMESPACE, not a vocabulary — any `x-ai-*` key is declared, and
|
|
26
|
+
* this package does not enumerate specific keys.
|
|
27
|
+
* - The key itself must be one ajv can register: after the prefix, only
|
|
28
|
+
* `[A-Za-z0-9_$:-]` (ajv holds a keyword name to
|
|
29
|
+
* `/^[a-z_$][a-z0-9_$:-]*$/i`). A dot, a space, a slash, an `@`, a `+` or
|
|
30
|
+
* any non-ASCII character makes the engine gate reject the whole document
|
|
31
|
+
* — as a root-pathed `ValidationFinding`, not an error.
|
|
32
|
+
* - A value under a declared `x-ai-*` key must be JSON — `CanonicalJson`
|
|
33
|
+
* fails typed (`NonJsonValueError`) on anything else, the same as
|
|
34
|
+
* every other emitted value.
|
|
35
|
+
* - The one recommended, non-binding key is `x-ai-hint`: a string carrying
|
|
36
|
+
* an instruction to a machine reader about the annotated value.
|
|
37
|
+
* - `x-ai-example` is deliberately NOT recommended. Draft-07's own
|
|
38
|
+
* `examples` keyword already exists, is carried by the assembly, and
|
|
39
|
+
* classifies as a CONTRACT change in `DocumentDiff`; an `x-ai-*`
|
|
40
|
+
* key classifies as ANNOTATIONS. The two would be two example channels
|
|
41
|
+
* with opposite version semantics.
|
|
42
|
+
* - A declared-family value must not contain an `$id` — or a repeated
|
|
43
|
+
* `$anchor` — at ANY depth, not merely as its own top-level key: ajv's
|
|
44
|
+
* reference collection walks unknown keywords looking for them, so a
|
|
45
|
+
* colliding one buried anywhere inside an annotation payload fails the
|
|
46
|
+
* compile, surfacing as a blocking root-pathed `ValidationFinding` rather
|
|
47
|
+
* than a silent no-op. An empty-string `$id` collides too — it resolves
|
|
48
|
+
* to the root id.
|
|
49
|
+
* - No upstream tool sanctions `x-ai-`: a document carrying it that is
|
|
50
|
+
* submitted to schemastore.org needs the corresponding entry added to
|
|
51
|
+
* that repo's own validation config. Until then it is intended for
|
|
52
|
+
* self-hosted publication.
|
|
53
|
+
* - `DocumentDiff` classifies a delta confined to `x-ai-*` keys as
|
|
54
|
+
* `"annotations"`, so adopting the family on an already-published
|
|
55
|
+
* versioned document rewrites that file in place — correct, because
|
|
56
|
+
* annotations are transparently replaceable.
|
|
57
|
+
*
|
|
19
58
|
* Both consumers of the registry route through {@link KeywordFamilies.isDeclared}:
|
|
20
59
|
* `DocumentLint`'s `UnknownKeyword` check (a declared key is not flagged) and
|
|
21
60
|
* `AnnotationCarriers` (only declared keys are re-grafted after the Draft-07
|
|
@@ -31,7 +70,7 @@ const VSCODE_KEYWORDS = /* @__PURE__ */ new Set([
|
|
|
31
70
|
/**
|
|
32
71
|
* The declared non-standard keyword families as one predicate: the
|
|
33
72
|
* vscode-json-languageservice set by exact name, plus the `x-taplo`,
|
|
34
|
-
* `x-tombi-` and `x-
|
|
73
|
+
* `x-tombi-`, `x-intellij-` and `x-ai-` prefixes.
|
|
35
74
|
*
|
|
36
75
|
* @public
|
|
37
76
|
*/
|
|
@@ -40,10 +79,11 @@ var KeywordFamilies = class {
|
|
|
40
79
|
/**
|
|
41
80
|
* Whether `key` belongs to a declared non-standard keyword family.
|
|
42
81
|
* Draft-07's own keywords are a separate vocabulary — this predicate
|
|
43
|
-
* answers only for the language-server extension families
|
|
82
|
+
* answers only for the language-server extension families and the
|
|
83
|
+
* house `x-ai-` machine-annotation namespace.
|
|
44
84
|
*/
|
|
45
85
|
static isDeclared(key) {
|
|
46
|
-
return VSCODE_KEYWORDS.has(key) || key.startsWith("x-taplo") || key.startsWith("x-tombi-") || key.startsWith("x-intellij-");
|
|
86
|
+
return VSCODE_KEYWORDS.has(key) || key.startsWith("x-taplo") || key.startsWith("x-tombi-") || key.startsWith("x-intellij-") || key.startsWith("x-ai-");
|
|
47
87
|
}
|
|
48
88
|
};
|
|
49
89
|
|
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
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
|
-
> development against a single pinned Effect v4
|
|
11
|
+
> development against a single pinned Effect v4 prerelease. Packages graduate to
|
|
12
12
|
> `1.0.0` once Effect `4.0.0` ships. To hold your own `effect` versions at
|
|
13
13
|
> exactly the ones the kit is built and tested against, install
|
|
14
14
|
> [`@effected/pnpm-plugin-effect`](https://www.npmjs.com/package/@effected/pnpm-plugin-effect).
|
|
@@ -106,10 +106,14 @@ console.log(Effect.runSync(program));
|
|
|
106
106
|
|
|
107
107
|
The declared families are always admitted — `KeywordFamilies` is the one registry, consumed by both the carriers and the lint, so the two cannot drift on what counts as declared. A caller-supplied `includeAnnotationKey` predicate is consulted in addition, but know the boundary: keys it admits outside the declared families reach the Draft 2020-12 document and are still dropped by the lowering.
|
|
108
108
|
|
|
109
|
+
`KeywordFamilies` splits into two groups. The upstream families above are mirrored from SchemaStore's own CONTRIBUTING guide — vocabularies the language servers already read. The `x-ai-` prefix (with the trailing dash — bare `x-ai` and a look-alike like `x-aida-foo` are not declared) is different: a house machine-annotation namespace this package owns rather than mirrors, meant for a machine reader rather than an editor. It is a namespace, not a fixed vocabulary — any key under the prefix is declared, and the one recommended (non-binding) key is `x-ai-hint`, a string instruction to a machine reader about the annotated value. A key under the prefix must be one ajv can register: after `x-ai-` only `[A-Za-z0-9_$:-]` (ajv holds a keyword name to `/^[a-z_$][a-z0-9_$:-]*$/i`), so a dot, a space, a slash, an `@`, a `+` or a non-ASCII character makes the engine gate reject the whole document as a finding. A declared-family value must itself be JSON, and must not contain an `$id` — or a repeated `$anchor` — at any depth, not merely as its own top-level key: ajv's reference collection walks unknown keywords looking for them, and a colliding one fails the compile. An empty-string `$id` resolves to the root id and collides too. No upstream tool sanctions `x-ai-` today, so it is intended for self-hosted publication rather than submission to schemastore.org without that repo's own validation-config entry. Because `x-ai-*` advises a reader rather than asserting anything, `DocumentDiff` classifies it as an annotation: adopting the family on an already-published versioned document rewrites that file in place, the same as any other prose change.
|
|
110
|
+
|
|
109
111
|
## Catalog entries and versioning
|
|
110
112
|
|
|
111
113
|
`CatalogEntry` is the `catalog.json` entry as a `Schema.Class`, so decoding an existing entry and encoding one for submission are the same artifact. `SchemaVersion` is a **full three-component SemVer** label — `major.minor.patch` with an optional prerelease, enforced by `@effected/semver` — so ordering is plain SemVer precedence (`1.10.0` above `1.9.0`) and the label round-trips verbatim. Build metadata is rejected (`1.0.0+build.5` does not parse): SemVer precedence ignores it, so two labels differing only in build would compare equal and both claim to be the latest. Surrounding whitespace is rejected for the same round-tripping reason. The file-name convention is SchemaStore's own `<name>-<version>.json`; the label grammar is the one deliberate divergence, since the store's corpus uses partial labels like `1.2` that no SemVer parser accepts and that cannot be split back out of a file name unambiguously.
|
|
112
114
|
|
|
115
|
+
`SchemaVersioning.isPinned(version)` answers whether a label names a published document rather than a prerelease — SemVer §9 makes a prerelease's own instability explicit, so a contract change inside one breaks nobody's pin. It is the one predicate the pipeline's contract gate and `SchemaVersioning.next` both read, so the two can never disagree about the same label. `next(current, change)` is the version a `WriteChange` classification calls for: identity for anything but a `"contract"` change on a pinned label, otherwise a MINOR bump on the 0.x line (0.x treats MINOR as the breaking axis) or MAJOR above it — always strictly greater, never a minted prerelease.
|
|
116
|
+
|
|
113
117
|
`CatalogEntry.assemble` derives both catalog modes from the same inputs. Pass `versions` for the versioned mode — the `versions` map carries every label and `url` points at the latest version's file — or omit it for the unversioned single-file mode. An empty `versions` array is a contradiction and throws; pass `undefined` instead.
|
|
114
118
|
|
|
115
119
|
```ts
|
|
@@ -270,9 +274,32 @@ SchemaPipeline.run(targets, { blocking: (finding) => finding.source === "validat
|
|
|
270
274
|
|
|
271
275
|
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.
|
|
272
276
|
|
|
273
|
-
Findings come back as values and are never logged, so the wording of your build output stays yours. A blocking finding fails with `SchemaGateError` carrying every finding that blocked, and the
|
|
277
|
+
Findings come back as values and are never logged, so the wording of your build output stays yours. A blocking finding fails with `SchemaGateError` carrying every finding that blocked. `run` is **two-phase and all-or-nothing across targets**: every target is generated, gated, and — for a target the contract policy guards — classified against its predecessor before any file is touched, and only then are the held documents written, in order. A gate or contract failure on the third target therefore leaves the first two unwritten too, not merely the third; nothing is written unless every target passes both gates.
|
|
278
|
+
|
|
279
|
+
### The contract gate
|
|
280
|
+
|
|
281
|
+
A schema with a pinned `version` — `SchemaVersioning.isPinned`'s name for a label with no prerelease — is a published document: consumers pin its URL. `SchemaPipeline.run` will not silently rewrite one in place when the new document's validation contract has moved out from under it: by default (`contractChanges: "block-versioned"`), a `"contract"` change on such a target fails the whole run with `SchemaContractChangeError` before anything is written, carrying every affected target's `$id`, `path`, `version` and the `nextVersion` it should publish under instead (`SchemaVersioning.next`). A target with no `version`, or with a prerelease label, declares its own instability and is rewritten in place as before.
|
|
282
|
+
|
|
283
|
+
```ts
|
|
284
|
+
import { SchemaContractChangeError, SchemaPipeline } from "@effected/schemastore";
|
|
285
|
+
import { Effect } from "effect";
|
|
286
|
+
|
|
287
|
+
declare const targets: Parameters<typeof SchemaPipeline.run>[0];
|
|
288
|
+
|
|
289
|
+
const program = SchemaPipeline.run(targets).pipe(
|
|
290
|
+
Effect.catchTag("SchemaContractChangeError", (error: SchemaContractChangeError) =>
|
|
291
|
+
Effect.succeed(
|
|
292
|
+
error.targets.map((target) => `${target.$id}: ${target.version} -> ${target.nextVersion}`),
|
|
293
|
+
),
|
|
294
|
+
),
|
|
295
|
+
);
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
Pass `contractChanges: "allow"` to classify and report only, never refuse — the same behavior the pipeline had before this gate existed. That is also the sanctioned repair path for a published file whose on-disk text no longer parses: `SchemaFile` classifies unparseable text as `"contract"` so a corrupted generated file stays regenerable, and the default policy would otherwise refuse that exact repair.
|
|
299
|
+
|
|
300
|
+
The contract gate is only coherent when `version` participates in a target's `path` (`schemas/<version>/<name>-<version>.json`) — a target at a fixed path compares the same file forever, so bumping `version` alone does not move where the next write lands.
|
|
274
301
|
|
|
275
|
-
`SchemaPipeline.check(targets)` is the same walk with no writes, answering `wouldWrite`, `change` and `
|
|
302
|
+
`SchemaPipeline.check(targets)` is the same walk with no writes, answering `wouldWrite`, `change`, `blocked` and `contractBlocked` per target. Where `run` enforces, `check` reports: it is total over the targets and never stops at a failing gate or a blocked contract, so a repo with several broken documents learns about all of them in one run rather than one per run. `blocked` and `contractBlocked` answer different questions side by side — the first, whether findings would block under `blocking`; the second, whether the contract policy would refuse the write under `contractChanges` — so a drift job can print the right remedy instead of telling a refused target to "just regenerate."
|
|
276
303
|
|
|
277
304
|
`runOne` and `checkOne` take a single target and answer its one result directly, so a one-target caller need not prove element zero exists.
|
|
278
305
|
|
|
@@ -289,13 +316,13 @@ The classification is key-order insensitive and keyword-position aware, like the
|
|
|
289
316
|
## Features
|
|
290
317
|
|
|
291
318
|
- `StoreDocument` — the assembly pipeline: `fromSchema` / `fromSchemaResult`, the `draft07` constructor for hand-built documents, the flat `toJson()` publication shape, `serializeResult()`, the `DRAFT_07_META_SCHEMA` constant and `SchemaConversionError`.
|
|
292
|
-
- `AnnotationCarriers` / `KeywordFamilies` — the post-lowering re-graft and the one registry of declared keyword families, consumed by both the carriers and the lint so they cannot disagree.
|
|
293
|
-
- `SchemaVersioning` / `SchemaVersion` — full-SemVer version labels with `parseResult` / `parse`, the `Order` instance and `latest`, plus `fileName`, `schemaUrl` and `catalogUrls` deriving both catalog modes.
|
|
319
|
+
- `AnnotationCarriers` / `KeywordFamilies` — the post-lowering re-graft and the one registry of declared keyword families (upstream language-server families plus the house `x-ai-` machine-annotation namespace), consumed by both the carriers and the lint so they cannot disagree.
|
|
320
|
+
- `SchemaVersioning` / `SchemaVersion` — full-SemVer version labels with `parseResult` / `parse`, the `Order` instance and `latest`, `isPinned` and `next` for the contract-change version bump, plus `fileName`, `schemaUrl` and `catalogUrls` deriving both catalog modes.
|
|
294
321
|
- `CatalogEntry` — the `catalog.json` entry as a `Schema.Class`, `assemble` and the `fileMatch` hygiene lint (`CatalogLintFinding`).
|
|
295
322
|
- `DocumentLint` — the total structural lint returning `DocumentLintFinding` values, never an error.
|
|
296
323
|
- `SchemaValidator` — real-engine validation, closed by default over ajv: provide `SchemaValidator.layer` and it works. `ValidationFinding`, `SchemaValidatorError`, `noop` to switch validation off and the `makeTest` / `layerTest` doubles.
|
|
297
324
|
- `DocumentDiff` — `classify` puts two documents in `"none"` / `"annotations"` / `"contract"`, the signal for whether a change needs a new schema version, plus `isClean` for the clean case.
|
|
298
|
-
- `SchemaPipeline` — the emit loop over a target manifest: `run` and `check`, the single-target `runOne` and `checkOne`, `PipelineFinding`, `SchemaGateError` and an overridable gating predicate.
|
|
325
|
+
- `SchemaPipeline` — the emit loop over a target manifest, two-phase and all-or-nothing across targets: `run` and `check`, the single-target `runOne` and `checkOne`, `PipelineFinding`, `SchemaGateError` and an overridable gating predicate, plus the contract gate (`ContractChangePolicy`, `ContractChangeTarget`, `SchemaContractChangeError`, `PipelineCheckResult.contractBlocked`) that refuses to rewrite a published document's validation contract in place.
|
|
299
326
|
- `SchemaFile` — write-if-changed IO over core `FileSystem` / `Path`, comparing by content and answering what changed as a value; `check` is the non-writing drift half, answering `wouldWrite` alongside `change`.
|
|
300
327
|
- `SchemaTarget` — the target manifest vocabulary: schema, `$id`, destination path, an optional name and an optional version that requires one.
|
|
301
328
|
- `CanonicalJson` — the deterministic serializer with typed failures (`NonJsonValueError`, `JsonDepthExceededError`).
|
package/SchemaPipeline.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { SchemaVersion, SchemaVersioning } from "./SchemaVersioning.js";
|
|
1
2
|
import { DocumentLint } from "./DocumentLint.js";
|
|
2
3
|
import { SchemaFile } from "./SchemaFile.js";
|
|
3
4
|
import { SchemaValidator } from "./SchemaValidator.js";
|
|
@@ -55,7 +56,38 @@ var SchemaGateError = class extends Schema.TaggedError()("SchemaGateError", {
|
|
|
55
56
|
return `Schema "${this.$id}" failed its gate with ${this.findings.length} blocking finding(s)`;
|
|
56
57
|
}
|
|
57
58
|
};
|
|
59
|
+
/**
|
|
60
|
+
* One published document whose validation contract would change.
|
|
61
|
+
*
|
|
62
|
+
* @public
|
|
63
|
+
*/
|
|
64
|
+
var ContractChangeTarget = class extends Schema.Class("ContractChangeTarget")({
|
|
65
|
+
/** The target's `$id`. */
|
|
66
|
+
$id: Schema.String,
|
|
67
|
+
/** The path the document would have been written to. */
|
|
68
|
+
path: Schema.String,
|
|
69
|
+
/** The target's pinned label. */
|
|
70
|
+
version: SchemaVersion,
|
|
71
|
+
/** The label to publish under instead — {@link SchemaVersioning.next} of `version`. */
|
|
72
|
+
nextVersion: SchemaVersion
|
|
73
|
+
}) {};
|
|
74
|
+
/**
|
|
75
|
+
* Indicates that at least one published target's contract changed under the
|
|
76
|
+
* active {@link ContractChangePolicy}. Raised BEFORE any target is written
|
|
77
|
+
* and total over the targets, so two broken documents surface in one run.
|
|
78
|
+
*
|
|
79
|
+
* @public
|
|
80
|
+
*/
|
|
81
|
+
var SchemaContractChangeError = class extends Schema.TaggedError()("SchemaContractChangeError", {
|
|
82
|
+
/** Every published target whose contract would change, in target order. */
|
|
83
|
+
targets: Schema.Array(ContractChangeTarget) }) {
|
|
84
|
+
get message() {
|
|
85
|
+
return `${this.targets.length} published schema document(s) would change their validation contract; nothing was written. Bump each target's version, $id and path together — ${this.targets.map((target) => `${target.$id}: ${target.version} → ${target.nextVersion}`).join(", ")} — or pass contractChanges: "allow" to rewrite in place.`;
|
|
86
|
+
}
|
|
87
|
+
};
|
|
58
88
|
const defaultBlocking = (finding) => finding.severity === "warning";
|
|
89
|
+
const contractGuardApplies = (target, options) => (options?.contractChanges ?? "block-versioned") === "block-versioned" && target.version !== void 0 && SchemaVersioning.isPinned(target.version);
|
|
90
|
+
const contractBlocks = (target, change, options) => contractGuardApplies(target, options) && change === "contract";
|
|
59
91
|
const gather = (document, options) => Effect.gen(function* () {
|
|
60
92
|
const validator = yield* SchemaValidator;
|
|
61
93
|
const lint = DocumentLint.lint(document).map((finding) => PipelineFinding.make({
|
|
@@ -119,20 +151,72 @@ var SchemaPipeline = class SchemaPipeline {
|
|
|
119
151
|
constructor() {}
|
|
120
152
|
/**
|
|
121
153
|
* Run every target: build its document, gather both gates' findings,
|
|
122
|
-
* fail with a {@link SchemaGateError} if any block,
|
|
154
|
+
* fail with a {@link SchemaGateError} if any block, refuse a published
|
|
155
|
+
* target whose contract changed, and write otherwise.
|
|
156
|
+
*
|
|
157
|
+
* **The run writes nothing unless every target passes both gates.** It
|
|
158
|
+
* is two-phase: every target is generated, gated and (for a target the
|
|
159
|
+
* {@link ContractChangePolicy} guards) compared against its predecessor
|
|
160
|
+
* before any file is touched, and only then are the held documents
|
|
161
|
+
* written in target order. A gate failure on the third target therefore
|
|
162
|
+
* leaves the first two unwritten.
|
|
163
|
+
*
|
|
164
|
+
* Phase 2 itself is sequential and **not transactional**: a filesystem or
|
|
165
|
+
* serialization failure part-way through it leaves the targets already
|
|
166
|
+
* written in place, with no rollback. The guarantee is "no writes unless
|
|
167
|
+
* every gate passes", not "every write or none".
|
|
168
|
+
*
|
|
169
|
+
* The gates run in a fixed precedence: {@link SchemaGateError} is
|
|
170
|
+
* fail-fast on the first blocked target, because a document the engine
|
|
171
|
+
* rejects would never be written under any contract policy, so its
|
|
172
|
+
* classification is noise. {@link SchemaContractChangeError} is total
|
|
173
|
+
* over the remaining targets, so two published documents whose contracts
|
|
174
|
+
* moved surface in one run.
|
|
175
|
+
*
|
|
176
|
+
* @example
|
|
177
|
+
* ```ts
|
|
178
|
+
* import { SchemaContractChangeError, SchemaPipeline } from "@effected/schemastore";
|
|
179
|
+
* import { Effect } from "effect";
|
|
180
|
+
*
|
|
181
|
+
* declare const targets: Parameters<typeof SchemaPipeline.run>[0];
|
|
123
182
|
*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
183
|
+
* const program = SchemaPipeline.run(targets).pipe(
|
|
184
|
+
* Effect.catchTag("SchemaContractChangeError", (error: SchemaContractChangeError) =>
|
|
185
|
+
* Effect.succeed(
|
|
186
|
+
* error.targets.map((target) => `${target.$id}: ${target.version} -> ${target.nextVersion}`),
|
|
187
|
+
* ),
|
|
188
|
+
* ),
|
|
189
|
+
* );
|
|
190
|
+
* ```
|
|
127
191
|
*/
|
|
128
192
|
static run(targets, options) {
|
|
129
193
|
return Effect.gen(function* () {
|
|
130
194
|
const files = yield* SchemaFile;
|
|
131
|
-
const
|
|
195
|
+
const held = [];
|
|
196
|
+
const blocked = [];
|
|
132
197
|
for (const target of targets) {
|
|
133
198
|
const document = yield* StoreDocument.fromSchema(target.schema, { $id: target.$id });
|
|
134
199
|
const findings = yield* gather(document, options);
|
|
135
200
|
yield* gate(target, findings, options);
|
|
201
|
+
const version = target.version;
|
|
202
|
+
if (version !== void 0 && contractGuardApplies(target, options)) {
|
|
203
|
+
const { change } = yield* files.check(target.path, document, options?.write);
|
|
204
|
+
if (contractBlocks(target, change, options)) blocked.push(ContractChangeTarget.make({
|
|
205
|
+
$id: target.$id,
|
|
206
|
+
path: target.path,
|
|
207
|
+
version,
|
|
208
|
+
nextVersion: SchemaVersioning.next(version, "contract")
|
|
209
|
+
}));
|
|
210
|
+
}
|
|
211
|
+
held.push({
|
|
212
|
+
target,
|
|
213
|
+
document,
|
|
214
|
+
findings
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
if (blocked.length > 0) return yield* Effect.fail(SchemaContractChangeError.make({ targets: blocked }));
|
|
218
|
+
const results = [];
|
|
219
|
+
for (const { target, document, findings } of held) {
|
|
136
220
|
const { outcome, change } = yield* files.write(target.path, document, options?.write);
|
|
137
221
|
results.push({
|
|
138
222
|
$id: target.$id,
|
|
@@ -172,6 +256,7 @@ var SchemaPipeline = class SchemaPipeline {
|
|
|
172
256
|
path: target.path,
|
|
173
257
|
wouldWrite,
|
|
174
258
|
blocked: blockingFindings(findings, options).length > 0,
|
|
259
|
+
contractBlocked: contractBlocks(target, change, options),
|
|
175
260
|
change,
|
|
176
261
|
findings
|
|
177
262
|
});
|
|
@@ -197,4 +282,4 @@ var SchemaPipeline = class SchemaPipeline {
|
|
|
197
282
|
};
|
|
198
283
|
|
|
199
284
|
//#endregion
|
|
200
|
-
export { PipelineFinding, SchemaGateError, SchemaPipeline };
|
|
285
|
+
export { ContractChangeTarget, PipelineFinding, SchemaContractChangeError, SchemaGateError, SchemaPipeline };
|
package/SchemaValidator.js
CHANGED
|
@@ -95,9 +95,12 @@ var SchemaValidator = class SchemaValidator extends Context.Service()("@effected
|
|
|
95
95
|
* `validate` checks the document against the Draft-07 meta-schema and
|
|
96
96
|
* then compiles it, reporting BOTH as {@link ValidationFinding} values:
|
|
97
97
|
* meta-schema failures keep ajv's structured `instancePath` and
|
|
98
|
-
* `keyword`, while a
|
|
99
|
-
*
|
|
100
|
-
*
|
|
98
|
+
* `keyword`, while a rejection ajv raises by *throwing* becomes a
|
|
99
|
+
* root-pathed finding — both a strict-mode compile failure and a
|
|
100
|
+
* declared keyword whose NAME ajv's own grammar
|
|
101
|
+
* (`/^[a-z_$][a-z0-9_$:-]*$/i`) refuses, such as an `x-ai-*` key
|
|
102
|
+
* carrying a dot or a space. The error channel stays reserved for the
|
|
103
|
+
* engine failing as a mechanism.
|
|
101
104
|
*
|
|
102
105
|
* `strict` defaults to `true` — SchemaStore's gate. Each call builds its
|
|
103
106
|
* own ajv instance, so documents sharing an `$id` never collide.
|
|
@@ -110,9 +113,9 @@ var SchemaValidator = class SchemaValidator extends Context.Service()("@effected
|
|
|
110
113
|
});
|
|
111
114
|
const declared = /* @__PURE__ */ new Set();
|
|
112
115
|
collectDeclaredKeywords(document, declared, 0);
|
|
113
|
-
for (const keyword of declared) ajv.addKeyword({ keyword });
|
|
114
|
-
if (!ajv.validateSchema(document)) return (ajv.errors ?? []).map(findingFromAjvError);
|
|
115
116
|
try {
|
|
117
|
+
for (const keyword of declared) ajv.addKeyword({ keyword });
|
|
118
|
+
if (!ajv.validateSchema(document)) return (ajv.errors ?? []).map(findingFromAjvError);
|
|
116
119
|
ajv.compile(document);
|
|
117
120
|
} catch (cause) {
|
|
118
121
|
return [ValidationFinding.make({
|
package/SchemaVersioning.js
CHANGED
|
@@ -42,6 +42,13 @@ const orderingKey = (label) => {
|
|
|
42
42
|
if (Result.isFailure(result)) throw new Error(`SchemaVersion ordering invariant violated for label "${label}"`);
|
|
43
43
|
return result.success;
|
|
44
44
|
};
|
|
45
|
+
const bumpBreaking = (current, parsed) => {
|
|
46
|
+
try {
|
|
47
|
+
return parsed.major === 0 ? parsed.bump.minor() : parsed.bump.major();
|
|
48
|
+
} catch (cause) {
|
|
49
|
+
throw new Error(`SchemaVersion bump invariant violated: "${current}" cannot be bumped past Number.MAX_SAFE_INTEGER (${Number.MAX_SAFE_INTEGER})`, { cause });
|
|
50
|
+
}
|
|
51
|
+
};
|
|
45
52
|
const assertSimpleName = (name) => {
|
|
46
53
|
if (name.length === 0 || /[/\\\s]/.test(name)) throw new Error(`Schema name must be a non-empty simple file base name, got "${name}"`);
|
|
47
54
|
};
|
|
@@ -90,6 +97,55 @@ var SchemaVersioning = class SchemaVersioning {
|
|
|
90
97
|
return versions.length === 0 ? Option.none() : Option.some(versions.reduce((max, v) => SchemaVersioning.Order(v, max) > 0 ? v : max));
|
|
91
98
|
}
|
|
92
99
|
/**
|
|
100
|
+
* Whether a label names a pinned, published document — i.e. it is NOT a
|
|
101
|
+
* prerelease. SemVer §9 makes a prerelease's own instability explicit, so
|
|
102
|
+
* a contract change inside one is not a break for anyone.
|
|
103
|
+
*
|
|
104
|
+
* ONE predicate consumed by two policies so they cannot drift:
|
|
105
|
+
* `SchemaPipeline`'s `"block-versioned"` guard (a pinned versioned target
|
|
106
|
+
* refuses an in-place contract change) and {@link SchemaVersioning.next}
|
|
107
|
+
* (a non-pinned label is not bumped). If the two used different tests, a
|
|
108
|
+
* caller could be refused a write AND told to keep the same label — a
|
|
109
|
+
* deadlock.
|
|
110
|
+
*/
|
|
111
|
+
static isPinned(version) {
|
|
112
|
+
return orderingKey(version).prerelease.length === 0;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* The version label a change classification calls for. Pure and
|
|
116
|
+
* synchronous; total over validated labels — a non-label input is a wiring
|
|
117
|
+
* bug and dies as a defect, the same as {@link SchemaVersioning.Order}.
|
|
118
|
+
*
|
|
119
|
+
* - `change !== "contract"` (`"none"`, `"annotations"`, `"created"`) →
|
|
120
|
+
* `current`. A created file has no predecessor to break; an annotation
|
|
121
|
+
* change is transparently replaceable (`DocumentDiff`).
|
|
122
|
+
* - `current` is not pinned (a prerelease) → `current`. A prerelease
|
|
123
|
+
* declares its own instability; the pipeline's `"block-versioned"`
|
|
124
|
+
* policy uses the same {@link SchemaVersioning.isPinned}, so the gate
|
|
125
|
+
* and the bump agree.
|
|
126
|
+
* - `major === 0` → MINOR bump (`0.4.0` → `0.5.0`): on the 0.x line MINOR
|
|
127
|
+
* is the breaking axis.
|
|
128
|
+
* - otherwise → MAJOR bump (`5.0.0` → `6.0.0`).
|
|
129
|
+
*
|
|
130
|
+
* The bump's job is to be strictly greater and conspicuous, NOT to encode
|
|
131
|
+
* SemVer compatibility: `DocumentDiff` cannot tell an added optional
|
|
132
|
+
* property from a removed required one, so every contract change reads as
|
|
133
|
+
* breaking. Each label is its own file and URL, so an over-bump costs a
|
|
134
|
+
* file; an under-bump would overwrite a pinned document. `next` never
|
|
135
|
+
* introduces a prerelease from a stable input.
|
|
136
|
+
*
|
|
137
|
+
* A component that would be bumped past `Number.MAX_SAFE_INTEGER` cannot be
|
|
138
|
+
* represented, and throws this module's explicit invariant `Error` naming
|
|
139
|
+
* the label and the ceiling rather than `SemVer.make`'s bare schema failure.
|
|
140
|
+
*/
|
|
141
|
+
static next(current, change) {
|
|
142
|
+
if (change !== "contract" || !SchemaVersioning.isPinned(current)) return current;
|
|
143
|
+
const label = bumpBreaking(current, orderingKey(current)).toString();
|
|
144
|
+
const reparsed = SchemaVersioning.parseResult(label);
|
|
145
|
+
if (Result.isFailure(reparsed)) throw new Error(`SchemaVersion bump invariant violated: "${current}" bumped to "${label}"`);
|
|
146
|
+
return reparsed.success;
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
93
149
|
* Derives the schema file name for a catalog name: `name.json`
|
|
94
150
|
* unversioned, `name-<version>.json` versioned.
|
|
95
151
|
*
|