Skip to content

Architecture ​

metaschema is a library: a schema is an object in your process, built once from a definition, and every check is a function call on it. This page follows a definition from the object you write to the function that checks a value, then a call through check, parse and the async methods, and maps each step to the module that does it. The topic pages go deeper; the links say where.

The big picture ​

A definition is parsed once, when the schema is built: every field becomes an instance of a type and a struct becomes a frozen plan of its fields. The checks are compiled into closures on their first use, so a schema that is only read (rendered, written as a snapshot) never compiles one. schema[RUN] is the check of the whole schema. check and parse call it with a context of their own and turn what it recorded into a result; the JIT backend replaces it with one generated function and changes nothing else. Nothing is read from the definition after construction, and nothing global is touched by a check.

The packages ​

The core entry validates; everything that is rendered from a schema, or built on top of it, is a subpath that imports the core and registers nothing until it is asked to. The core itself is two halves over the same classes: the definition half, which builds a schema and reads it, and the validation half, which installs check, parse and the rest into it. @alexify/metaschema/definition is the first half alone.

The sizes are min+gzip, a subpath measured as what it adds to the core (./mongodb to the core and ./coerce, which it requires), ./definition on its own. Each subpath exports functions and an explicit register() that installs them as methods or fills a table of the core; nothing in the core requires a subpath, so a bundler drops the ones a program does not import.

LayerModules (under src/)Responsibility
Definitionsschema.js, preprocessor.js, types.js, kinds.js, metadata.jsread a definition (the first key decides), the type registry, kinds and their metadata
Typesprototypes/*.js, rules.jsone prototype per type: what it reads from a definition; the rules (length, min/max, decimals, pattern, format)
Structsstruct.jsthe null-prototype field dictionary and its plan
Modelsmodel.js, snapshot.jsentities ordered by dependency, the snapshot of a model and of a schema
Checksvalidation.js, checks.jsthe validation half: the check of every type, field and struct, compiled on first use, and what validation.js installs into the classes above
Validationcontext.js, issues.js, result.js, locales/, lint.jsthe context of one call, issues as data, the async drain, messages rendered once, the warnings
Parsecodec.jscoercion, defaults, strip, decoders and serializers as passes over the built types
Subpathsdefinition/, generators/, compile/, model/, stream/, formats/, derive/, coerce/, mongodb/each bundled only where it is imported
Platformruntime/node.js, runtime/browser.jswriteFile behind saveTypes, the only file that touches a Node builtin

From a definition to a check ​

new Schema(name, definition) hands the definition to the Preprocessor, which picks a parser by the type of the source and, for an object, by its first key: a capitalized key is a kind, type first is the long form, a type name first is a shorthand, anything else is a nested struct. A Schema or a field that is already built is taken as it is.

A broken definition throws a SchemaDefinitionError with a code here, at construction, never inside check. Every field is then built: the type's class copies the rules (a rule the type does not accept throws ERR_INVALID_RULE, bounds that leave nothing between them ERR_INVALID_RULE too), and a struct records a frozen plan entry per field ({ key, type, required, own, aliases }). On the first use of a field's check, the compile() of its type (checks.js, installed on the prototype by validation.js) returns the type check as a closure, wrapped in the rules and the validate only when the field has them and in the nullable test last; a struct compiles one loop over its plan the same way, and a schema its own check through a stub of schema[RUN] that takes the closures' place. See Performance for what the loop does and does not do.

The two halves of the core ​

The classes (Schema, Model, the class of every type) are built by the definition half and validated by the other. validation.js installs, once, when the core entry loads it: check, checkAsync, parse, parseAsync, serialize, validate, ~standard, warnings and checkConsistency on Schema, warnings on Model, the compile of every built-in type on the prototype object the classes of that type inherit from (so a registry built before has it too), and the builders the lazy checks call. Until then, a schema of @alexify/metaschema/definition builds, renders and writes its snapshot, and a call that validates is ERR_NOT_REGISTERED. The state the validation half keeps on a schema (whether its root joins the cycle detection, its default coercion, the caches of parse and ~standard) is held under symbols the definition half sets; a test of the module graph pins that the definition half reaches no module of the validation half.

A check ​

The context is one object per call ({ issues, count, limit, path, seen, … }, always one shape); a container pushes its key on path before its children and pops it after them, and a leaf never touches it. An issue is recorded as data with an empty message; messages are rendered once, after the walk, through the locale of the call, and the lines with the location only when result.errors is read. count >= limit stops every loop at maxErrors.

Parse ​

parse is the same check between two passes of codec.js over the built types. A pass returns after one bit test when the schema carries nothing for it, so a schema without defaults and decoders parses at about the speed of its check. With coercion on, the pass before the check enters every field and converts the input through the converters ./coerce registered.

Both passes copy a container at the first part that changes and never write to the value given, so a plain schema returns the very object it was given. See Parse and serialize.

Async ​

checkAsync and parseAsync walk the value once, the same way. A validator that returns a promise is kept with the path it was met at, and a validator met later is deferred only when it lies over a path that is still pending; everything else runs at once.

The rule that a validator runs only when nothing under it failed holds across promises, and maxErrors stops the settle. See Async.

References ​

A field named with a capitalized name ('Company') is resolved when it is checked, through the model (root.findReference), and a name that no model resolves is the schema's own when it is its name (parent: '?Category' needs no model). Whether the value is the target's id or the target's record is decided per call:

Cycles are tracked only where a record is embedded, and maxDepth (128) bounds a deep graph. See References.

The model ​

The snapshot is the contract other programs read a model through (a schema writes the same document for itself and the entities it reaches, with its name as root); @alexify/metaschema/model restores it, compares two of them and classifies the change. See Domain models and Model evolution.

The JIT backend ​

compile(schema) of @alexify/metaschema/compile renders the same plan into one function and installs it as schema[RUN]; register() does it for every schema built afterwards, through a slot the core reads once per construction.

Built-in types are inlined; a reference, a custom type and a validate function stay calls into a scope. The generated code records issues through the same constructors as the closures, so the result of a check is the same object either way. See The JIT backend.

Released under the MIT License.