@uniflowed/validator 0.0.0-alpha.4 → 0.0.0-alpha.5

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/infer.js ADDED
@@ -0,0 +1,107 @@
1
+ // @flow
2
+ //
3
+ // `@uniflowed/validator/infer`: reading a value's type off its schema.
4
+ //
5
+ // This module compiles to nothing. Every name in it is a type, and the reason
6
+ // they are here rather than beside the parsers is that they are the answer to
7
+ // a question the parsers do not ask: given a schema, what is the type of the
8
+ // thing it produces, and what is the type of the thing you may hand it?
9
+ //
10
+ // const Account = object({
11
+ // email: pipe(string(), email()),
12
+ // age: pipe(string(), transform(Number), min(18)),
13
+ // });
14
+ //
15
+ // type Raw = InferInput<typeof Account>; // {| email: string, age: string |}
16
+ // type Account = InferOutput<typeof Account>; // {| email: string, age: number |}
17
+ //
18
+ // Neither type was written down. That is the point: a schema is the single
19
+ // source of truth, and a hand-written `type Account` beside it is a second
20
+ // truth that will disagree with the first on a Tuesday.
21
+ //
22
+ // # What Flow can do here
23
+ //
24
+ // More than this package used to assume. Conditional types with `infer` are
25
+ // what read a parameter back out of `Schema<Out, In>`; mapped types are what
26
+ // turn a shape — an object whose values are schemas — into an object whose
27
+ // values are those schemas' outputs, key by key, without naming the keys.
28
+ // `object`, `partial`, `looseObject`, `tuple`, `union`, `variant` and `record`
29
+ // all infer, so none of them takes an explicit type argument any more, and a
30
+ // field added to a shape appears in the inferred type with nothing else
31
+ // edited.
32
+ //
33
+ // The inferred object types are **exact**. `InferOutput<typeof Account>` does
34
+ // not accept an extra property, which matches what `object()` does at run time
35
+ // — it drops keys the shape does not name — and is why `looseObject` has a
36
+ // separate spelling with `...` in its result.
37
+ //
38
+ // # What it cannot
39
+ //
40
+ // **Paths are not typed.** An `Issue`'s `path` is `$ReadOnlyArray<string>`.
41
+ // Flow has no template-literal types, so there is no way to say "one of the
42
+ // paths that exist in this schema", and a `SchemaPath<S>` alias that was
43
+ // really `string` would be a type that looks like it checks something and does
44
+ // not. `@uniflowed/form` makes the same call for the same reason.
45
+ //
46
+ // **Branded outputs are not nominal.** `pipe(string(), brand("UserId"))` is
47
+ // `Schema<string, string>`. Flow's opaque types are per module and cannot be
48
+ // generated from a call, so the brand is a label in the description — real for
49
+ // `json-schema.js`, and honest about being nothing to the checker. A project
50
+ // that wants `UserId` to be a distinct type should declare
51
+ // `opaque type UserId = string` in the module that owns it and annotate there.
52
+ //
53
+ // **`InferInput` describes shape, not provenance.** It says a valid input to
54
+ // `pipe(string(), transform(Number))` is a `string`. It cannot say that the
55
+ // string has to parse as a number, because that is what the parse is for.
56
+ //
57
+ // **A recursive schema still needs one annotation.** `lazy(() => …)` builds a
58
+ // type that mentions itself, and Flow will not solve for that on its own; the
59
+ // type argument on `lazy` is where the cycle is cut. `lazy.js` has the
60
+ // example.
61
+
62
+ import type { Schema } from "./schema.js";
63
+
64
+ /** The type a schema produces. The one every consumer wants. */
65
+ export type InferOutput<TSchema> = TSchema extends Schema<infer TValue, infer TSource>
66
+ ? TValue
67
+ : empty;
68
+
69
+ /**
70
+ * The type a valid input to a schema has.
71
+ *
72
+ * Equal to [`InferOutput`] until a `transform` is in the pipeline. Where they
73
+ * differ, this is the one a form's `defaultValues` wants and the other is the
74
+ * one its `onValid` receives.
75
+ */
76
+ export type InferInput<TSchema> = TSchema extends Schema<infer TValue, infer TSource>
77
+ ? TSource
78
+ : empty;
79
+
80
+ /** The older name for [`InferOutput`], kept so existing annotations compile. */
81
+ export type Infer<TSchema> = InferOutput<TSchema>;
82
+
83
+ /** An object whose values are schemas: what `object` and `variant` take. */
84
+ export type Shape = { readonly [string]: Schema<mixed, mixed>, ... };
85
+
86
+ /** A list of schemas: what `union` and `tuple` take. */
87
+ export type Options = $ReadOnlyArray<Schema<mixed, mixed>>;
88
+
89
+ /** The object a shape produces, key by key. */
90
+ export type ShapeOutput<TShape extends Shape> = {
91
+ [Key in keyof TShape]: InferOutput<TShape[Key]>,
92
+ };
93
+
94
+ /** The object a shape accepts, key by key. */
95
+ export type ShapeInput<TShape extends Shape> = {
96
+ [Key in keyof TShape]: InferInput<TShape[Key]>,
97
+ };
98
+
99
+ /** The tuple a list of schemas produces, position by position. */
100
+ export type ItemsOutput<TItems extends Options> = {
101
+ [Index in keyof TItems]: InferOutput<TItems[Index]>,
102
+ };
103
+
104
+ /** The tuple a list of schemas accepts, position by position. */
105
+ export type ItemsInput<TItems extends Options> = {
106
+ [Index in keyof TItems]: InferInput<TItems[Index]>,
107
+ };
package/issue.js ADDED
@@ -0,0 +1,129 @@
1
+ // @flow
2
+ //
3
+ // `@uniflowed/validator/issue`: what a failure is, and where it happened.
4
+ //
5
+ // A validator that answers "no" has told the caller nothing. A form needs to
6
+ // put a message under one input, an HTTP handler needs to name the field in
7
+ // its 422 body, and a log needs to say which row of a thousand was wrong. All
8
+ // three want the same thing: the path from the root of the value to the place
9
+ // that failed, as data.
10
+ //
11
+ // So an issue is `{ code, message, path }` — `["users", "2", "email"]`, not
12
+ // `"users[2].email"`. Segments compose without a grammar: joining them with a
13
+ // dot is one line at the boundary that wants a string, and splitting a string
14
+ // back into segments is a parser nobody should have to write. Array indices
15
+ // are their decimal spelling, because a path is a path whether the container
16
+ // was an object or an array, and a consumer that has to branch on the segment
17
+ // type gains nothing from the distinction.
18
+ //
19
+ // # Why this is separate from the schemas
20
+ //
21
+ // Because nothing here knows what a schema is. `issue.js` is a leaf: it is
22
+ // imported by the kernel, by every combinator that reports a failure, and by
23
+ // `@uniflowed/form`, and it imports one thing itself. Keeping it that way is
24
+ // what lets a consumer translate issues — into field errors, into a response
25
+ // body — without resolving the schema engine at all.
26
+
27
+ import { put } from "./plain-object.js";
28
+
29
+ /** Where an issue happened, as object keys and array indices from the root. */
30
+ export type Path = $ReadOnlyArray<string>;
31
+
32
+ /**
33
+ * The mutable buffer the synchronous walk descends with.
34
+ *
35
+ * `schema.js` explains why one array is pushed and popped rather than a fresh
36
+ * array being allocated per field. The type is separate from [`Path`] so that
37
+ * the distinction between "the buffer, which is being mutated right now" and
38
+ * "a path, which is a value" is visible in every signature.
39
+ */
40
+ export type PathBuffer = Array<string>;
41
+
42
+ /**
43
+ * One reason a value was rejected.
44
+ *
45
+ * `code` is for programs — `"type"`, `"min_length"`, `"unknown_key"` — and is
46
+ * stable across message changes, so a caller can tell "this is not an email
47
+ * address" from "we need an email address" without matching on prose.
48
+ *
49
+ * `path` is absent rather than empty when the issue is about the whole value,
50
+ * because the overwhelmingly common case is a successful parse and an object
51
+ * with one fewer field is one fewer allocation on the path that matters.
52
+ */
53
+ export type Issue = {|
54
+ readonly code: string,
55
+ readonly message: string,
56
+ readonly path?: Path,
57
+ |};
58
+
59
+ /**
60
+ * An issue at wherever the walk currently is.
61
+ *
62
+ * The `slice` is the only copy of a path the package makes, and it happens
63
+ * exactly when a value was going to be rejected anyway.
64
+ */
65
+ export function issue(code: string, message: string, path: Path): Issue {
66
+ return path.length === 0 ? { code, message } : { code, message, path: path.slice() };
67
+ }
68
+
69
+ /** An issue at `path` with `keys` appended: a cross-field rule's landing spot. */
70
+ export function issueUnder(code: string, message: string, path: Path, keys: Path): Issue {
71
+ const at = path.concat(keys);
72
+ return at.length === 0 ? { code, message } : { code, message, path: at };
73
+ }
74
+
75
+ function describeIssue(entry: Issue): string {
76
+ const at = entry.path == null || entry.path.length === 0 ? "" : ` at ${entry.path.join(".")}`;
77
+ return `${entry.message}${at}`;
78
+ }
79
+
80
+ /**
81
+ * What [`parse`] raises.
82
+ *
83
+ * A real `Error` subclass so it survives `instanceof`, logging and a `catch`
84
+ * that only knows about errors, and it carries `issues` so a caller can build
85
+ * a field-by-field response without parsing the message back apart.
86
+ */
87
+ export class ValidationError extends Error {
88
+ readonly issues: $ReadOnlyArray<Issue>;
89
+
90
+ constructor(issues: $ReadOnlyArray<Issue>) {
91
+ super(issues.map(describeIssue).join("; "));
92
+ this.name = "ValidationError";
93
+ this.issues = issues;
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Issues grouped the way a form renders them.
99
+ *
100
+ * `root` is everything that was about the value as a whole; `nested` is keyed
101
+ * by the dotted path, which is the same string `@uniflowed/form`'s `register`
102
+ * was given. Written through [`put`] because a payload's own `__proto__` key
103
+ * reaches this function as a path segment.
104
+ */
105
+ export type FlatIssues = {|
106
+ readonly root: $ReadOnlyArray<string>,
107
+ readonly nested: { readonly [string]: $ReadOnlyArray<string>, ... },
108
+ |};
109
+
110
+ /** Group `issues` by their path, for a caller that renders per field. */
111
+ export function flatten(issues: $ReadOnlyArray<Issue>): FlatIssues {
112
+ const root: Array<string> = [];
113
+ const nested: { [string]: Array<string>, ... } = {};
114
+ for (const entry of issues) {
115
+ const path = entry.path;
116
+ if (path == null || path.length === 0) {
117
+ root.push(entry.message);
118
+ continue;
119
+ }
120
+ const key = path.join(".");
121
+ const already = Object.hasOwn(nested, key) ? nested[key] : null;
122
+ if (already == null) {
123
+ put(nested, key, [entry.message]);
124
+ } else {
125
+ already.push(entry.message);
126
+ }
127
+ }
128
+ return { root, nested };
129
+ }
package/json-schema.js ADDED
@@ -0,0 +1,307 @@
1
+ // @flow
2
+ //
3
+ // `@uniflowed/validator/json-schema`: a schema as a document somebody else can
4
+ // read.
5
+ //
6
+ // const { schema, unrepresentable } = toJsonSchema(Account);
7
+ // // { $schema: "…/2020-12/schema", type: "object", properties: { … } }
8
+ //
9
+ // The roadmap calls this "schema exports", and it is the reason `schema.js`
10
+ // makes every schema carry a [`Description`] beside its parse function. A
11
+ // closure cannot be read: a schema that was *only* the four lines that check a
12
+ // value can be run and can never be published, documented, handed to an
13
+ // OpenAPI generator, or turned into types for a consumer written in another
14
+ // language. The description is the readable half, and this module is the first
15
+ // consumer of it.
16
+ //
17
+ // # What "unrepresentable" means, and why it is a return value
18
+ //
19
+ // JSON Schema describes JSON. `map`, `set`, `bigint`, `date`, `instance` and
20
+ // `custom` describe values that JSON does not have, and `check` describes a
21
+ // predicate no declarative format can express. A converter has three options
22
+ // for those: throw, lie, or say so. Throwing makes one `Date` field fatal for a
23
+ // document that is otherwise fine. Lying — emitting `{}` and moving on — is
24
+ // what makes an exported schema quietly weaker than the one the application
25
+ // runs.
26
+ //
27
+ // So this says so. The node becomes `{}`, which accepts anything and is
28
+ // therefore *never wrong*, only imprecise, and the place it happened is
29
+ // reported in `unrepresentable` with the path to it. A caller that wants the
30
+ // strict behaviour asserts the list is empty; a caller documenting an API that
31
+ // happens to have one `Date` in it gets its document.
32
+ //
33
+ // # The exported document describes the input, not the output
34
+ //
35
+ // `pipe(string(), transform(Number))` exports as `{ "type": "string" }`. That
36
+ // is the whole point of a JSON Schema: it validates what arrives on the wire,
37
+ // and what arrives is the input. `InferOutput` is for the code on this side of
38
+ // the boundary; the document is for the code on the other side, which has
39
+ // never heard of the transform.
40
+ //
41
+ // The consequence is easy to get wrong, and this module got it wrong first: a
42
+ // step *after* a transform constrains the output, not the input.
43
+ // `pipe(string(), transform(Number), min(18))` must not export as `{ "type":
44
+ // "string", "minimum": 18 }`, which would ask a consumer to compare a string
45
+ // against a number. It exports as `{ "type": "string" }`, and `min after a
46
+ // transform` is reported as unrepresentable — because the schema does reject
47
+ // `"5"` and the document does not, and that is exactly what the list is for.
48
+ //
49
+ // `fallback` follows the same rule to its conclusion: a fallback accepts every
50
+ // value, so it exports as `{}`. Nothing is lost that JSON Schema could have
51
+ // held.
52
+ //
53
+ // # Recursion
54
+ //
55
+ // A `lazy` schema is a cycle, and the description of one is a thunk rather
56
+ // than a value for exactly that reason. Each `lazy` carries an identity, and
57
+ // the walk keeps a table from that identity to a name under `$defs`: the first
58
+ // visit converts the body, and every visit after it emits a `$ref`. A comment
59
+ // tree comes out as one definition and a reference to it, which is what JSON
60
+ // Schema is for.
61
+ //
62
+ // # What this is not, yet
63
+ //
64
+ // `uf prepare` lists a `GenerateValidatorTypes` step, and there is no code
65
+ // behind it — no crate reads a schema and writes a `.js.flow`. This module is
66
+ // the half that can exist without one: a description a generator would read,
67
+ // and a converter proving the description is complete enough to build
68
+ // something from. When that step is implemented it should consume
69
+ // [`describe`], not re-derive a shape from the source.
70
+
71
+ import type { Path } from "./issue.js";
72
+ import { put } from "./plain-object.js";
73
+ import type { Constraint, Description, Schema } from "./schema.js";
74
+ import { describe } from "./schema.js";
75
+
76
+ /** One node of a JSON Schema document. */
77
+ export type JsonSchemaNode = { readonly [string]: mixed, ... };
78
+
79
+ /** Somewhere the document is less precise than the schema it came from. */
80
+ export type Unrepresentable = {|
81
+ readonly path: Path,
82
+ readonly kind: string,
83
+ |};
84
+
85
+ /** A JSON Schema document, and everything JSON Schema could not say. */
86
+ export type JsonSchemaExport = {|
87
+ readonly schema: JsonSchemaNode,
88
+ readonly unrepresentable: $ReadOnlyArray<Unrepresentable>,
89
+ |};
90
+
91
+ const DIALECT = "https://json-schema.org/draft/2020-12/schema";
92
+
93
+ /** `left` with `right`'s keywords on top, written prototype-safely. */
94
+ function merge(left: JsonSchemaNode, right: JsonSchemaNode): JsonSchemaNode {
95
+ const out: { [string]: mixed, ... } = {};
96
+ for (const key of Object.keys(left)) {
97
+ put(out, key, left[key]);
98
+ }
99
+ for (const key of Object.keys(right)) {
100
+ put(out, key, right[key]);
101
+ }
102
+ return out;
103
+ }
104
+
105
+ /**
106
+ * Whether a field may be absent.
107
+ *
108
+ * `optional`, `nullish` and a default all mean the key can be missing, and any
109
+ * of them may be wrapped in the steps of a pipeline — `pipe(optional(string()),
110
+ * …)` is an `optional` under a `constrained` — so the wrappers are unwrapped
111
+ * before the question is asked. `nullable` is not here: `null` is a value, and
112
+ * a key holding it is present.
113
+ */
114
+ function mayBeAbsent(description: Description): boolean {
115
+ return match (description) {
116
+ {kind: "optional", inner: _} => true,
117
+ {kind: "nullish", inner: _} => true,
118
+ {kind: "default", inner: _} => true,
119
+ {kind: "fallback", inner: _} => true,
120
+ {kind: "constrained", inner: const inner, constraint: _} => mayBeAbsent(inner),
121
+ {kind: "transformed", inner: const inner} => mayBeAbsent(inner),
122
+ _ => false,
123
+ };
124
+ }
125
+
126
+ /**
127
+ * Whether a description is of a value some step already changed.
128
+ *
129
+ * Only the spine of pipeline wrappers is followed. A transform *inside* an
130
+ * object's field does not make the object transformed: the field's own node is
131
+ * where that is handled.
132
+ */
133
+ function describesTransformedValue(description: Description): boolean {
134
+ return match (description) {
135
+ {kind: "transformed", inner: _} => true,
136
+ {kind: "constrained", inner: const inner, constraint: _} => describesTransformedValue(inner),
137
+ _ => false,
138
+ };
139
+ }
140
+
141
+ /** What to call a constraint in the `unrepresentable` list. */
142
+ function labelFor(constraint: Constraint): string {
143
+ return constraint.kind === "opaque" ? `check ${constraint.label}` : constraint.kind;
144
+ }
145
+
146
+ /** The JSON Schema keywords one `pipe` step contributes, if any. */
147
+ function keywordsFor(constraint: Constraint): JsonSchemaNode {
148
+ return match (constraint) {
149
+ {kind: "minLength", value: const value} => { minLength: value },
150
+ {kind: "maxLength", value: const value} => { maxLength: value },
151
+ {kind: "length", value: const value} => { minLength: value, maxLength: value },
152
+ {kind: "minItems", value: const value} => { minItems: value },
153
+ {kind: "maxItems", value: const value} => { maxItems: value },
154
+ {kind: "min", value: const value} => { minimum: value },
155
+ {kind: "max", value: const value} => { maximum: value },
156
+ {kind: "integer"} => { type: "integer" },
157
+ {kind: "multipleOf", value: const value} => { multipleOf: value },
158
+ {kind: "pattern", source: const source} => { pattern: source },
159
+ {kind: "format", name: const name} => { format: name },
160
+ {kind: "brand", name: const name} => { title: name },
161
+ {kind: "opaque", label: _} => {},
162
+ };
163
+ }
164
+
165
+ /**
166
+ * Convert `schema` to a JSON Schema document.
167
+ *
168
+ * The walk keeps three pieces of state — the definitions a recursive schema
169
+ * needs, the names already given out, and the places JSON Schema could not
170
+ * say what the schema meant — which is why it is a closure over a converter
171
+ * rather than a free function.
172
+ */
173
+ export function toJsonSchema(schema: Schema<mixed, mixed>): JsonSchemaExport {
174
+ const definitions: { [string]: JsonSchemaNode, ... } = {};
175
+ const names = new Map<symbol, string>();
176
+ const unrepresentable: Array<Unrepresentable> = [];
177
+
178
+ function unsupported(kind: string, path: Path): JsonSchemaNode {
179
+ unrepresentable.push({ path: path.slice(), kind });
180
+ return {};
181
+ }
182
+
183
+ function object(
184
+ entries: $ReadOnlyArray<[string, Description]>,
185
+ unknownKeys: "strip" | "reject" | "keep",
186
+ path: Path,
187
+ ): JsonSchemaNode {
188
+ const properties: { [string]: JsonSchemaNode, ... } = {};
189
+ const required: Array<string> = [];
190
+ for (const [key, inner] of entries) {
191
+ put(properties, key, convert(inner, path.concat(key)));
192
+ if (!mayBeAbsent(inner)) {
193
+ required.push(key);
194
+ }
195
+ }
196
+ const base = { type: "object", properties, required };
197
+ return unknownKeys === "reject" ? merge(base, { additionalProperties: false }) : base;
198
+ }
199
+
200
+ function recursive(id: symbol, inner: () => Description, path: Path): JsonSchemaNode {
201
+ const already = names.get(id);
202
+ if (already != null) {
203
+ return { $ref: `#/$defs/${already}` };
204
+ }
205
+ const name = `definition${String(names.size)}`;
206
+ names.set(id, name);
207
+ put(definitions, name, convert(inner(), path));
208
+ return { $ref: `#/$defs/${name}` };
209
+ }
210
+
211
+ function convert(description: Description, path: Path): JsonSchemaNode {
212
+ return match (description) {
213
+ {kind: "unknown"} => {},
214
+ {kind: "never"} => { not: {} },
215
+ {kind: "string"} => { type: "string" },
216
+ {kind: "number"} => { type: "number" },
217
+ {kind: "boolean"} => { type: "boolean" },
218
+ {kind: "null"} => { type: "null" },
219
+ {kind: "bigint"} => unsupported("bigint", path),
220
+ {kind: "undefined"} => unsupported("undefined", path),
221
+ {kind: "date"} => unsupported("date", path),
222
+ {kind: "instance", name: const name} => unsupported(`instance ${name}`, path),
223
+ {kind: "custom", name: const name} => unsupported(`custom ${name}`, path),
224
+ {kind: "map", key: _, value: _} => unsupported("map", path),
225
+ {kind: "set", item: _} => unsupported("set", path),
226
+ {kind: "literal", value: const value} => { const: value },
227
+ {kind: "enum", values: const values} => { enum: values.slice() },
228
+ {kind: "array", item: const item} =>
229
+ {
230
+ type: "array",
231
+ items: convert(item, path.concat("*")),
232
+ },
233
+ {kind: "tuple", items: const items} =>
234
+ {
235
+ type: "array",
236
+ prefixItems: items.map((item, index) => convert(item, path.concat(String(index)))),
237
+ minItems: items.length,
238
+ maxItems: items.length,
239
+ },
240
+ {kind: "record", value: const value} =>
241
+ {
242
+ type: "object",
243
+ additionalProperties: convert(value, path.concat("*")),
244
+ },
245
+ {kind: "object", entries: const entries, unknownKeys: const unknownKeys} =>
246
+ object(entries, unknownKeys, path),
247
+ {kind: "union", options: const options} =>
248
+ {
249
+ anyOf: options.map((option) => convert(option, path)),
250
+ },
251
+ {kind: "variant", key: _, branches: const branches} =>
252
+ {
253
+ oneOf: branches.map(([, branch]) => convert(branch, path)),
254
+ },
255
+ {kind: "intersect", parts: const parts} =>
256
+ {
257
+ allOf: parts.map((part) => convert(part, path)),
258
+ },
259
+ {kind: "optional", inner: const inner} => convert(inner, path),
260
+ {kind: "default", inner: const inner} => convert(inner, path),
261
+ {kind: "nullable", inner: const inner} =>
262
+ {
263
+ anyOf: [convert(inner, path), { type: "null" }],
264
+ },
265
+ {kind: "nullish", inner: const inner} =>
266
+ {
267
+ anyOf: [convert(inner, path), { type: "null" }],
268
+ },
269
+ {kind: "fallback", inner: _} => {},
270
+ {kind: "transformed", inner: const inner} => convert(inner, path),
271
+ {kind: "lazy", id: const id, inner: const inner} => recursive(id, inner, path),
272
+ {kind: "constrained", inner: const inner, constraint: const constraint} =>
273
+ constrained(inner, constraint, path),
274
+ };
275
+ }
276
+
277
+ /**
278
+ * A pipeline step's keywords on top of what it refined.
279
+ *
280
+ * Two of them contribute nothing. A `check`'s predicate is a closure, and a
281
+ * step that sits above a `transform` is about the output rather than the
282
+ * input this document describes. Both leave the node alone and add a line
283
+ * saying the document is looser than the schema at that path.
284
+ */
285
+ function constrained(inner: Description, constraint: Constraint, path: Path): JsonSchemaNode {
286
+ const node = convert(inner, path);
287
+ if (describesTransformedValue(inner)) {
288
+ unrepresentable.push({
289
+ path: path.slice(),
290
+ kind: `${labelFor(constraint)} after a transform`,
291
+ });
292
+ return node;
293
+ }
294
+ if (constraint.kind === "opaque") {
295
+ unrepresentable.push({ path: path.slice(), kind: labelFor(constraint) });
296
+ return node;
297
+ }
298
+ return merge(node, keywordsFor(constraint));
299
+ }
300
+
301
+ const root = convert(describe(schema), []);
302
+ const document =
303
+ Object.keys(definitions).length === 0
304
+ ? merge({ $schema: DIALECT }, root)
305
+ : merge(merge({ $schema: DIALECT }, root), { $defs: definitions });
306
+ return { schema: document, unrepresentable };
307
+ }
package/lazy.js ADDED
@@ -0,0 +1,93 @@
1
+ // @flow
2
+ //
3
+ // `@uniflowed/validator/lazy`: a schema that does not exist yet.
4
+ //
5
+ // A comment has replies, and a reply is a comment. There is no order in which
6
+ // to write that down, because the initialiser of `Comment` would have to name
7
+ // `Comment`, and at that moment it is still `undefined`. So the schema is
8
+ // wrapped in a function, and the function is not called until the first parse:
9
+ //
10
+ // type Comment = {| text: string, replies: $ReadOnlyArray<Comment> |};
11
+ //
12
+ // const Comment: Schema<Comment> = lazy(() =>
13
+ // object({ text: string(), replies: array(Comment) }),
14
+ // );
15
+ //
16
+ // The annotation is the other half, and it is not optional. Flow will infer
17
+ // the type of almost every schema in this package, but it will not solve a
18
+ // type that mentions itself; the `Schema<Comment>` on the binding is where the
19
+ // cycle is cut, and `infer.js` says so among the other limits.
20
+ //
21
+ // # Why the result is memoised
22
+ //
23
+ // Without it, every node of the tree calls `build()` and gets a fresh object
24
+ // graph — a thousand-comment thread would construct a thousand schemas, none
25
+ // of which is reused, on a parse that should have allocated nothing. With it,
26
+ // recursion costs one closure for the whole schema.
27
+ //
28
+ // # Why there are two of these
29
+ //
30
+ // Everything else in the package works out whether it is asynchronous when it
31
+ // is built: `object` asks its fields, `array` asks its item. A lazy schema
32
+ // cannot ask, because the thing it would ask does not exist yet, and building
33
+ // it to find out is the infinite loop the laziness was there to avoid.
34
+ //
35
+ // So the answer is declared instead of discovered. [`lazy`] is synchronous and
36
+ // [`lazyAsync`] is not, and a recursive schema with a `checkAsync` anywhere
37
+ // inside it must use the second — otherwise the objects and arrays above it
38
+ // build their synchronous halves, and the first `await` that never happens
39
+ // surfaces as a thrown "use safeParseAsync" from the middle of a parse rather
40
+ // than as a type the caller could have seen.
41
+
42
+ import type { Description, Schema } from "./schema.js";
43
+ import { describe, makeAsyncSchema, makeSchema, run, runAsync } from "./schema.js";
44
+
45
+ /** Build once, then hand back the same schema for the life of the process. */
46
+ function memoise<TOutput, TInput>(
47
+ build: () => Schema<TOutput, TInput>,
48
+ ): () => Schema<TOutput, TInput> {
49
+ let built: null | Schema<TOutput, TInput> = null;
50
+ return () => {
51
+ const already = built;
52
+ if (already != null) {
53
+ return already;
54
+ }
55
+ const made = build();
56
+ built = made;
57
+ return made;
58
+ };
59
+ }
60
+
61
+ /**
62
+ * A synchronous schema built on first use.
63
+ *
64
+ * `id` is what a converter keys its definitions on: `json-schema.js` sees the
65
+ * same symbol every time it walks back around the cycle, which is how a
66
+ * recursive schema becomes a `$ref` rather than a stack overflow.
67
+ */
68
+ export function lazy<TOutput, TInput = mixed>(
69
+ build: () => Schema<TOutput, TInput>,
70
+ ): Schema<TOutput, TInput> {
71
+ const resolve = memoise(build);
72
+ const id = Symbol("lazy");
73
+ const description = (): Description => ({
74
+ kind: "lazy",
75
+ id,
76
+ inner: () => describe(resolve()),
77
+ });
78
+ return makeSchema((value, path) => run(resolve(), value, path), description);
79
+ }
80
+
81
+ /** A recursive schema with an asynchronous step somewhere inside it. */
82
+ export function lazyAsync<TOutput, TInput = mixed>(
83
+ build: () => Schema<TOutput, TInput>,
84
+ ): Schema<TOutput, TInput> {
85
+ const resolve = memoise(build);
86
+ const id = Symbol("lazyAsync");
87
+ const description = (): Description => ({
88
+ kind: "lazy",
89
+ id,
90
+ inner: () => describe(resolve()),
91
+ });
92
+ return makeAsyncSchema((value, path) => runAsync(resolve(), value, path), description);
93
+ }