* feat(cordis): add volatile config references without remounting
* refactor(cordis): use internal prefix for volatile updates
* fix(schemastery): preserve metadata-free serialized schemas
* refactor(config): move volatile updates into an opt-in Loader plugin
* refactor(config): reuse Loader context patch for volatile updates
* refactor(config): let the volatile watcher commit equal candidates
* refactor(config): separate comparison from volatile reconciliation
* refactor(loader): extract pure entry option diff
* refactor(loader): compare volatile config through schema metadata
* refactor(loader): make volatile comparison policy explicit
* refactor: name config reload and volatile HMR events
* refactor(loader): compare volatile config in one schema walk; declare hmr/volatile-update in volatile-hmr
Replace the mask/project pair in vendor/loader/src/config/diff.ts with one recursive schema-guided comparison. Absent objects now compare as their declared schema default at every object node, not only under volatile ancestors; both inputs resolve to the same validated config, so the one changed assertion (undefined vs {} under a non-volatile object schema) becomes unchanged. The isJsExpr special case is dropped: expression keys fall through to raw comparison.
Move the hmr/volatile-update Events declaration from vendored Cordis core to its producer, packages/boot/volatile-hmr, matching how dsh-hmr declares hmr/change and hmr/reload. Vendored cordis events.ts returns to upstream content; the inherited Cordis API catalog and the event matrix are regenerated, and the tutorial example imports the package types.
* fix(cosmokit): compare sparse array slots in deepEqual; extend volatile diff coverage
Array.prototype.every skipped holes, so deepEqual(Array(1), ['new']) was true in one direction. Compare every index with holes read as undefined.
config/diff.ts treats expression objects as opaque values even when a schema field shares the marker name, and looks up field schemas with Object.hasOwn so unknown keys named after Object prototype members compare raw. Tests cover absent-object defaults, sparse arrays in both directions, prototype-named keys, metadata-free serialized schema nodes, and a runtime remount check in volatile-hmr. READMEs and the architecture note describe the object-default rule.
* test(schemastery): build serialized schema fixtures without unknown casts
verify-no-unknown-casts rejects the two as-unknown assertions in scripts/volatile-config.spec.ts. Parse the serialized nodes from JSON text instead, matching scripts/loader-config-diff.spec.ts.
* docs(volatile-hmr): drop the scope-scan tag from hmr/volatile-update
verify-scoped-events rejects @dshScopeScan on an event that carries no scoped keys. Catalogs and the event matrix are regenerated.
* docs: regenerate module graph for the volatile-hmr dependency edge
verify-module-graph reports the merged docs stale: volatile-hmr now lists its app-boot dependency.
* test(volatile-hmr): watch the real YAML through an exact config watch
The Windows coverage lane lost the whole spec: its vitest worker exited without a report once this test started a chokidar directory watch (root: ['.']), the only real directory watch in the test suite. Register the include file through hmr.watchConfig instead, the pattern the HMR package's own real-file tests run on Windows, and emit loader/config-reload from the refresh callback as the root-watch path does. packages/boot/hmr/tests/coordination.spec.ts already covers that root-watch emission with a mocked watcher; volatile-hmr and hmr sources stay at 100% coverage.
* test(volatile-hmr): pass the Include path as a file URL
On Windows the plain absolute path made the include subtree fail to load, so the profile-patch test found no consumer entry. Other Include fixtures in this spec already pass file URLs.
* refactor(loader): commit volatile config in Entry.update and remove volatile-hmr
loader/config-reload was emitted from two places (app-boot profile reconciliation and the HMR root watch) and by hand in tests, because a separate plugin needed a "configuration applied" signal to run its own pass. Every update path already funnels through Entry.update, which is where the Loader decides to skip a remount for a volatile-only change. Entry._commitVolatile now parses the candidate through the fiber's internal/config hook and schema, commits the new values into the running references when the ordinary effective values still match, and emits loader/volatile-update to the owning fiber. A changed ordinary effective value falls back to the ordinary remount instead of leaving the instance stale; an invalid candidate is logged and keeps the running references, with the raw config retained for the next activation. Forced group updates take the same path.
Remove packages/boot/volatile-hmr, its event, the app-boot loader/config-reload declaration and emit, and the HMR emission. Loader runtime and real-file tests move to scripts/loader-volatile-update.spec.ts. Docs, catalogs, the event matrix, module graph and the architecture note follow.
* test: share the volatile schema fixture between the schema and Loader specs
The duplication gate flagged the identical Config fixture in scripts/volatile-config.spec.ts and scripts/loader-volatile-update.spec.ts.
* refactor(loader): reduce config/diff.ts to equalExceptVolatile
Entry.update now computes the strict raw option changes itself and only asks diff.ts whether two raw configs differ at most in volatile fields. The ignoreVolatile flag, the options object and the "config is the sole change" rule leave diff.ts; they were the Loader's decision and now sit next to the commit they gate. The raw-config deepEqual in the volatile branch is gone because the strict change list already answers it.
* refactor(loader): type the volatile comparison against Schemastery's Schema
config/diff.ts narrows the plugin's Standard Schema to Schemastery's own Schema type instead of a hand-written structural interface, which makes vendor/loader a type-only dependent of @deepseek-ai/schemastery. The rescope codemod treats the vendor identifier comparison like schemastery's own source and renames the import through an exact edit.
* fix(loader): address review findings on the volatile commit path
Entry.update only replaces the fiber's pending raw config for a volatile-only change, so a noSave direct update survives a no-op entry update. _commitVolatile logs at debug level when ordinary effective values changed and the ordinary update applies, dispatches loader/volatile-update with a Context receiver like Cordis' own filtered emits, and isolates a throwing listener. Schemastery reports unsupported volatile values as ValidationError with their path and validates a lazy inner once when it is built. The inherited-event catalog gains internal/config and corrected source lines; the Loader spec imports dsh-hmr by package name and covers the new behaviors. Docs record that class-instance ordinary fields always take the remount path and that .default() accepts the raw input type.
9.5 KiB
Schemastery
Type Driven Schema Validator.
Features
- Lightweight. Much smaller than other validation libraries.
- Easy to use. You can use any schema as a function or constructor directly.
- Powerful. Schemastery supports some advanced types such as
union,intersectandtransform. - Extensible. You can create your own schema types via
Schema.extend(). - Serializable. Schema objects can be serialized into JSON and then be hydrated in another environment.
Basic Examples
use as validator (JavaScript)
const Schema = require('schemastery')
const validate = Schema.number().default(10)
validate(0) // 0
validate(null) // 10
validate('') // TypeError
use as constructor (TypeScript)
import Schema from 'schemastery'
interface Config {
foo: Record<string, string>
bar: string[]
}
const Config = Schema.object({
foo: Schema.dict(Schema.string()).default({}),
bar: Schema.array(Schema.string()).default([]),
})
// config is an instance of Config
// in this case, that is { foo: {}, bar: [] }
const config = new Config()
General Types
Schema.any()
Assert that the value is of any type.
const validate = Schema.any()
validate() // undefined
validate(0) // 0
validate({}) // {}
Schema.never()
Assert that the value is nullable.
const validate = Schema.never()
validate() // undefined
validate(0) // TypeError
validate({}) // TypeError
Schema.const(value)
Assert that the value is equal to the given constant.
const validate = Schema.const(10)
validate(10) // 10
validate(0) // TypeError
Schema.number()
Assert that the value is a number.
const validate = Schema.number()
validate() // undefined
validate(1) // 1
validate('') // TypeError
Schema.string()
Assert that the value is a string.
const validate = Schema.string()
validate() // undefined
validate(0) // TypeError
validate('foo') // 'foo'
Schema.boolean()
Assert that the value is a boolean.
const validate = Schema.boolean()
validate() // undefined
validate(0) // TypeError
validate(true) // true
Schema.is(constructor)
Assert that the value is an instance of the given constructor.
const validate = Schema.is(RegExp)
validate() // undefined
validate(/foo/) // /foo/
validate('foo') // TypeError
Schema.array(inner)
Assert that the value is an array of inner. The default value will be [] if not specified.
const validate = Schema.array(Schema.number())
validate() // []
validate(0) // TypeError
validate([0, 1]) // [0, 1]
validate([0, '1']) // TypeError
Schema.dict(inner)
Assert that the value is a dictionary of inner. The default value will be {} if not specified.
const validate = Schema.dict(Schema.number())
validate() // {}
validate(0) // TypeError
validate({ a: 0, b: 1 }) // { a: 0, b: 1 }
validate({ a: 0, b: '1' }) // TypeError
Schema.tuple(list)
Assert that the value is a tuple whose each element is of corresponding subtype. The default value will be [] if not specified.
const validate = Schema.tuple([
Schema.number(),
Schema.string(),
])
validate() // []
validate([0]) // { a: 0 }
validate([0, 1]) // TypeError
validate([0, '1']) // [0, '1']
Schema.object(dict)
Assert that the value is an object whose each property is of corresponding subtype. The default value will be {} if not specified.
const validate = Schema.object({
a: Schema.number(),
b: Schema.string(),
})
validate() // {}
validate({ a: 0 }) // { a: 0 }
validate({ a: 0, b: 1 }) // TypeError
validate({ a: 0, b: '1' }) // { a: 0, b: '1' }
Schema.union(list)
Assert that the value is one of the specified types.
const validate = Schema.union([
Schema.number(),
Schema.string(),
])
validate() // undefined
validate(0) // 0
validate('1') // '1'
validate(true) // TypeError
Schema.intersect(list)
Assert that the value should match each specified type.
const validate = Schema.intersect([
Schema.object({ a: Schema.string().required() }),
Schema.object({ b: Schema.number().default(0) }),
])
validate() // TypeError
validate({ a: '' }) // { a: '', b: 0 }
validate({ a: '', b: 1 }) // { a: '', b: 1 }
validate({ a: '', b: '2' }) // TypeError
Schema.transform(inner, callback)
Assert that the value is of the specified subtype and then transformed by callback.
const validate = Schema.transform(Schema.number().default(0), n => n + 1)
validate() // 1
validate('0') // TypeError
validate(10) // 11
Instance Methods
Note: default and required are mutually exclusive.
schema.required()
Assert that the value is not nullable.
schema.default(value)
Set the fallback value when nullable.
schema.description(text)
Set the description of the schema.
schema.simplify(value)
Normalize a value by removing parts that are equal to schema defaults. This is useful when storing user configuration and keeping persisted files compact.
const Config = Schema.object({
foo: Schema.string().default(''),
bar: Schema.number().default(0),
})
Config.simplify({ foo: '', bar: 1 }) // { bar: 1 }
Validation Options
All schemas are callable. The second argument accepts validation options:
const Config = Schema.object({
foo: Schema.number(),
})
Config({ foo: '1' }, { autofix: true }) // {}
autofix: remove invalid object properties where possible.ignore: skip validation for selected values and schema nodes.path: provide an initial path for nested validation errors.
Shorthand Syntax
Some shorthand syntax is available for inner types.
undefined->Schema.any()String->Schema.string()Number->Schema.number()Boolean->Schema.boolean()1->Schema.const(1)(only for primitive types)Date->Schema.is(Date)
Schema.array(String) // Schema.array(Schema.string())
Schema.dict(RegExp) // Schema.dict(Schema.is(RegExp))
Schema.union([1, 2]) // Schema.union([Schema.const(1), Schema.const(2)])
You can also use Schema.from() to get the inferred schema from a shorthand value.
Schema.from() // Schema.any()
Schema.from(Date) // Schema.is(Date)
Schema.from('foo') // Schema.const('foo')
Advanced Examples
Here are some examples which demonstrate how to define advanced types.
Enumeration
const Enum = Schema.union(['red', 'blue'])
Enum('red') // 'red'
Enum('blue') // 'blue'
Enum('green') // TypeError
ToString
const ToString = Schema.transform(Schema.any(), v => String(v))
ToString('') // ''
ToString(0) // '0'
ToString({}) // '{}'
Listable
const Listable = Schema.union([
Schema.array(Number),
Schema.transform(Number, n => [n]),
]).default([])
Listable() // []
Listable(0) // [0]
Listable([1, 2]) // [1, 2]
Alias
const Config = Schema.dict(Number, Schema.union([
'foo',
Schema.transform('bar', () => 'foo'),
]))
Config({ foo: 1 }) // { foo: 1 }
Config({ bar: 2 }) // { foo: 2 }
Config({ bar: '3' }) // TypeError
Extensibility
Custom schema types are registered with Schema.extend(type, resolve). A
resolver receives the input value, schema node, validation options, and a strict
flag. Return [value] for accepted input, or [value, adapted] when the caller
should write an adapted value back to the source object.
Schema.extend('trimmed', (data, schema, options) => {
if (typeof data !== 'string') {
throw new Schema.ValidationError(`expected string but got ${data}`, options)
}
return [data.trim()]
})
Serializability
const schema1 = Schema.object({
foo: Schema.string(),
bar: Schema.number(),
})
// should have the same effect as schema1
const schema2 = new Schema(JSON.parse(JSON.stringify(schema1)))
Schemastery also exposes the Standard Schema ~standard property, so compatible
tools can validate values without depending on Schemastery-specific APIs.
Volatile configuration
.volatile() preserves a field's schema type and metadata while parsing its value into a stable reference read with .get(). Defaults remain ordinary data; absent optional fields return references containing undefined. simplify() unwraps references for persistence. See the configuration guide for supported placements and Cordis update semantics.