@mikrojs/native 0.18.3-next.20260829153835 → 0.19.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.
@@ -1,842 +0,0 @@
1
- /* Core schema machinery: types, constructors, the validator, and
2
- * applyDefaults. Dependency-free so hosts (CLI, registries) can import it via
3
- * shared.ts without resolving mikro/* builtins; mikro/schema re-exports it and
4
- * adds the Result-returning parse(). */
5
-
6
- function err<E>(error: E) {
7
- return {ok: false as const, error}
8
- }
9
-
10
- /* Declared outright rather than derived from the factory with ReturnType. The
11
- * `as const` this replaces existed for the `name` literal type, but it also made
12
- * every field readonly, which stopped the type reducing against structurally
13
- * equal error unions elsewhere (kv's KVError carries the same ValidationFailed
14
- * shape) and broke contextual typing at those call sites. */
15
- export type SchemaError = {name: 'ValidationFailed'; message: string; path: string}
16
- export const SchemaError = {
17
- ValidationFailed: (message: string, path: string): SchemaError => ({
18
- name: 'ValidationFailed',
19
- message,
20
- path,
21
- }),
22
- }
23
-
24
- // ── Schema types ────────────────────────────────────────────────────
25
-
26
- type Primitive = string | number | boolean
27
-
28
- /* A closed set of string shapes, deliberately not a caller-supplied regex: a
29
- * registry validates operator input against a published schema, so a pattern
30
- * from a publisher would be a denial-of-service vector on the registry (one
31
- * catastrophic-backtracking expression from anyone who can publish). These are
32
- * ours, fixed, and linear.
33
- *
34
- * `url` means any parseable absolute URL with a scheme, not http and https
35
- * only: mqtt:// and ws:// are ordinary device-config values. A scheme
36
- * allowlist is not expressible, which is the gap that would justify extending
37
- * this. ipv6 is deliberately absent: no demand, and it is the one shape whose
38
- * check is large enough to be worth its own decision. */
39
- export type Format = 'url' | 'hostname' | 'ipv4' | 'mac' | 'email'
40
-
41
- /* The unit an operator sees beside a number, and the one the app reads: the
42
- * annotation describes the stored value and never converts it.
43
- *
44
- * The set is the IANA SenML Units and Secondary Units registries, which is the
45
- * right basis rather than an invention of ours: ASCII by construction, scoped
46
- * to constrained devices, and already built on the two-tier model this needs,
47
- * where a secondary unit derives from a primary by scale and offset. Adopted
48
- * wholesale, minus the entries SenML marks NOT RECOMMENDED for new producers
49
- * (we are a new producer), plus the microcontroller units its secondary
50
- * registry lacks -- it is telemetry-shaped, so it has no us, kHz, mW, uA, mAh,
51
- * MiB, kohm or Bd.
52
- *
53
- * Two deliberate departures, both documented in docs/registry-spec.md:
54
- * `deg` is kept despite its NOT RECOMMENDED marking, because an operator types
55
- * degrees and not radians; `d` for day is dropped, because a bare `d` is the
56
- * SI deci- prefix and RFC 8428 guideline 7 forbids standalone prefix letters.
57
- *
58
- * Note SenML's `%` is NOT a percentage -- it is a synonym for the ratio `/`,
59
- * and the RFC says so explicitly. It is excluded, so a 0-100 field uses `/100`,
60
- * which a form renders as `%`. */
61
- export type Unit =
62
- | 'm'
63
- | 'kg'
64
- | 's'
65
- | 'A'
66
- | 'K'
67
- | 'cd'
68
- | 'mol'
69
- | 'Hz'
70
- | 'rad'
71
- | 'sr'
72
- | 'N'
73
- | 'Pa'
74
- | 'J'
75
- | 'W'
76
- | 'C'
77
- | 'V'
78
- | 'F'
79
- | 'Ohm'
80
- | 'S'
81
- | 'Wb'
82
- | 'T'
83
- | 'H'
84
- | 'Cel'
85
- | 'lm'
86
- | 'lx'
87
- | 'Bq'
88
- | 'Gy'
89
- | 'Sv'
90
- | 'kat'
91
- | 'm2'
92
- | 'm3'
93
- | 'm/s'
94
- | 'm/s2'
95
- | 'm3/s'
96
- | 'W/m2'
97
- | 'cd/m2'
98
- | 'bit'
99
- | 'bit/s'
100
- | 'lat'
101
- | 'lon'
102
- | 'pH'
103
- | 'dB'
104
- | 'dBW'
105
- | 'count'
106
- | '/'
107
- | '%RH'
108
- | '%EL'
109
- | 'EL'
110
- | '1/s'
111
- | 'S/m'
112
- | 'B'
113
- | 'VA'
114
- | 'VAs'
115
- | 'var'
116
- | 'vars'
117
- | 'J/m'
118
- | 'kg/m3'
119
- | 'deg'
120
- | 'NTU'
121
- | 'ms'
122
- | 'min'
123
- | 'h'
124
- | 'MHz'
125
- | 'kW'
126
- | 'kVA'
127
- | 'kvar'
128
- | 'Ah'
129
- | 'Wh'
130
- | 'kWh'
131
- | 'varh'
132
- | 'kvarh'
133
- | 'kVAh'
134
- | 'Wh/km'
135
- | 'KiB'
136
- | 'GB'
137
- | 'Mbit/s'
138
- | 'B/s'
139
- | 'MB/s'
140
- | 'mV'
141
- | 'mA'
142
- | 'dBm'
143
- | 'ug/m3'
144
- | 'mm/h'
145
- | 'm/h'
146
- | 'ppm'
147
- | '/100'
148
- | '/1000'
149
- | 'hPa'
150
- | 'mm'
151
- | 'cm'
152
- | 'km'
153
- | 'km/h'
154
- | 'ppb'
155
- | 'ppt'
156
- | 'VAh'
157
- | 'mg/l'
158
- | 'ug/l'
159
- | 'g/l'
160
- | 'us'
161
- | 'kHz'
162
- | 'GHz'
163
- | 'mW'
164
- | 'uA'
165
- | 'uV'
166
- | 'mAh'
167
- | 'MiB'
168
- | 'kB'
169
- | 'MB'
170
- | 'kbit/s'
171
- | 'KiB/s'
172
- | 'kohm'
173
- | 'Mohm'
174
- | 'kPa'
175
- | 'bar'
176
- | 'Bd'
177
-
178
- /* The `default` annotation is stored as an extra node property so a schema
179
- * serializes to JSON as-is; it is typed precisely on the constructor options
180
- * and loosely on the node, which keeps Infer free of recursive
181
- * instantiations. */
182
-
183
- /* Display annotations, carried by every node a form can render. They never
184
- * change what validates, so a consumer that does not render a form ignores
185
- * them. Not on optional(): the wrapper expresses absence, the node it wraps
186
- * expresses identity, so annotations go on the inner. */
187
- interface Annotated {
188
- readonly title?: string
189
- readonly description?: string
190
- }
191
-
192
- export interface StringSchema extends Annotated {
193
- readonly kind: 'string'
194
- readonly default?: string
195
- readonly mask?: boolean
196
- readonly minLength?: number
197
- readonly maxLength?: number
198
- readonly format?: Format
199
- }
200
-
201
- export interface NumberSchema extends Annotated {
202
- readonly kind: 'number'
203
- readonly default?: number
204
- readonly mask?: boolean
205
- readonly min?: number
206
- readonly max?: number
207
- readonly integer?: boolean
208
- readonly unit?: Unit
209
- }
210
-
211
- export interface BooleanSchema extends Annotated {
212
- readonly kind: 'boolean'
213
- readonly default?: boolean
214
- }
215
-
216
- export interface UnknownSchema {
217
- readonly kind: 'unknown'
218
- }
219
-
220
- export interface LiteralSchema<T extends Primitive = Primitive> extends Annotated {
221
- readonly kind: 'literal'
222
- readonly value: T
223
- readonly default?: T
224
- }
225
-
226
- export interface ArraySchema<S extends Schema = Schema> extends Annotated {
227
- readonly kind: 'array'
228
- readonly element: S
229
- readonly default?: unknown
230
- readonly minItems?: number
231
- readonly maxItems?: number
232
- }
233
-
234
- export interface ObjectSchema<
235
- Shape extends Record<string, Schema> = Record<string, Schema>,
236
- > extends Annotated {
237
- readonly kind: 'object'
238
- readonly shape: Shape
239
- }
240
-
241
- export interface OptionalSchema<S extends Schema = Schema> {
242
- readonly kind: 'optional'
243
- readonly inner: S
244
- }
245
-
246
- export interface TupleSchema<
247
- Elements extends readonly Schema[] = readonly Schema[],
248
- > extends Annotated {
249
- readonly kind: 'tuple'
250
- readonly elements: Elements
251
- readonly default?: unknown
252
- }
253
-
254
- export interface UnionSchema<
255
- Members extends readonly Schema[] = readonly Schema[],
256
- > extends Annotated {
257
- readonly kind: 'union'
258
- readonly members: Members
259
- readonly default?: unknown
260
- }
261
-
262
- export interface TaggedUnionSchema<
263
- Key extends string = string,
264
- Branches extends Record<string, ObjectSchema> = Record<string, ObjectSchema>,
265
- > extends Annotated {
266
- readonly kind: 'taggedUnion'
267
- readonly key: Key
268
- readonly branches: Branches
269
- readonly default?: unknown
270
- }
271
-
272
- export type Schema =
273
- | StringSchema
274
- | NumberSchema
275
- | BooleanSchema
276
- | UnknownSchema
277
- | LiteralSchema
278
- | ArraySchema
279
- | ObjectSchema
280
- | OptionalSchema
281
- | TupleSchema
282
- | UnionSchema
283
- | TaggedUnionSchema
284
-
285
- // ── Type inference ──────────────────────────────────────────────────
286
-
287
- type Simplify<T> = {[K in keyof T]: T[K]} & {}
288
-
289
- export type Infer<S> = S extends StringSchema
290
- ? string
291
- : S extends NumberSchema
292
- ? number
293
- : S extends BooleanSchema
294
- ? boolean
295
- : S extends UnknownSchema
296
- ? unknown
297
- : S extends LiteralSchema<infer T>
298
- ? T
299
- : S extends ArraySchema<infer E>
300
- ? ArraySchema extends S
301
- ? []
302
- : Infer<E>[]
303
- : S extends ObjectSchema<infer Shape>
304
- ? ObjectSchema extends S
305
- ? object
306
- : Simplify<InferObject<Shape>>
307
- : S extends TupleSchema<infer Elements>
308
- ? InferTuple<Elements>
309
- : S extends OptionalSchema<infer Inner>
310
- ? OptionalSchema extends S
311
- ? OptionalSchema
312
- : Infer<Inner> | undefined
313
- : S extends UnionSchema<infer Members>
314
- ? InferUnion<Members>
315
- : S extends TaggedUnionSchema<infer Key, infer Branches>
316
- ? InferTaggedUnion<Key, Branches>
317
- : never
318
-
319
- type InferObject<Shape> = {
320
- [K in keyof Shape as Shape[K] extends OptionalSchema ? never : K]: Infer<Shape[K]>
321
- } & {
322
- [K in keyof Shape as Shape[K] extends OptionalSchema ? K : never]?: Infer<Shape[K]>
323
- }
324
-
325
- type InferTuple<Elements> = Elements extends readonly [infer Head, ...infer Tail]
326
- ? [Infer<Head>, ...InferTuple<Tail>]
327
- : []
328
-
329
- type InferUnion<Members> = Members extends readonly [infer Head, ...infer Tail]
330
- ? Infer<Head> | InferUnion<Tail>
331
- : never
332
-
333
- type InferTaggedUnion<Key extends string, Branches> = {
334
- [Tag in keyof Branches & string]: {[K in Key]: Tag} & Infer<Branches[Tag]>
335
- }[keyof Branches & string]
336
-
337
- /* The read type: what applyDefaults alone can hand back. A field defaults
338
- * cannot fill is optional here, while Infer keeps it required: Infer is the
339
- * write type, where an operator must supply it. */
340
- export type InferRead<S> =
341
- S extends ObjectSchema<infer Shape>
342
- ? ObjectSchema extends S
343
- ? object
344
- : Simplify<InferReadObject<Shape>>
345
- : Infer<S>
346
-
347
- /* What the materialized defaults always contain: a node carrying its own
348
- * default, or a plain object whose fields ALL fill (or are optional). A
349
- * defaultless array and a partially fillable object are omitted whole, so
350
- * their fields read as absent until a document supplies them. Must stay in
351
- * lockstep with materializeDefaults in shared.ts. */
352
- type Filled<S> = S extends {default: unknown}
353
- ? true
354
- : S extends OptionalSchema
355
- ? false
356
- : S extends ObjectSchema<infer Shape>
357
- ? ObjectSchema extends S
358
- ? false
359
- : AllFilled<Shape>
360
- : false
361
-
362
- /* Every field fills or is optional; an empty shape fills as {}. */
363
- type AllFilled<Shape> = false extends {
364
- [K in keyof Shape]: Shape[K] extends OptionalSchema ? true : Filled<Shape[K]>
365
- }[keyof Shape]
366
- ? false
367
- : true
368
-
369
- type InferReadObject<Shape> = {
370
- [K in keyof Shape as Filled<Shape[K]> extends true ? K : never]: InferRead<Shape[K]>
371
- } & {
372
- [K in keyof Shape as Filled<Shape[K]> extends true ? never : K]?: InferRead<Shape[K]>
373
- }
374
-
375
- // ── Schema constructors ─────────────────────────────────────────────
376
-
377
- /* Display annotations every constructor accepts. Structural arguments stay
378
- * positional; annotations trail. */
379
- export interface DisplayOptions {
380
- readonly title?: string
381
- readonly description?: string
382
- }
383
-
384
- export interface ScalarOptions<T> extends DisplayOptions {
385
- readonly default?: T
386
- }
387
-
388
- export interface DefaultOption<T> extends DisplayOptions {
389
- readonly default?: T
390
- }
391
-
392
- /* `mask` says: do not display this value in cleartext. A form renders a
393
- * password input, and any other consumer that prints a config document
394
- * redacts. It is a display rule and nothing more: the value is stored,
395
- * transmitted and held on the device in plaintext exactly as any other. */
396
- export interface MaskableOptions<T> extends ScalarOptions<T> {
397
- readonly mask?: boolean
398
- }
399
-
400
- /* Constraints, unlike the display annotations, change what validates. A
401
- * consumer may ignore an annotation it does not recognise; it may not ignore
402
- * one of these, since doing so means accepting a value the author ruled out. */
403
- export interface StringOptions<T> extends MaskableOptions<T> {
404
- readonly minLength?: number
405
- readonly maxLength?: number
406
- readonly format?: Format
407
- }
408
-
409
- export interface NumberOptions<T> extends MaskableOptions<T> {
410
- readonly min?: number
411
- readonly max?: number
412
- readonly integer?: boolean
413
- readonly unit?: Unit
414
- }
415
-
416
- export interface ArrayOptions<T> extends DefaultOption<T> {
417
- readonly minItems?: number
418
- readonly maxItems?: number
419
- }
420
-
421
- /* A node interface types `default` as optional, so a defaulted node and a bare
422
- * one are the same type; the constructors record the annotation in their
423
- * return type instead, which is what lets InferRead see it. D is the inferred
424
- * type of the `default` option, undefined when none was written. */
425
- type Defaulted<S, D> = [D] extends [undefined] ? S : S & {readonly default: unknown}
426
-
427
- /* Defaults below a wholesale unit never fill: applyDefaults replaces the unit
428
- * whole, so only a unit-level default applies. Rejected where they are written
429
- * rather than at the validation that later misses the field. The walk stops at
430
- * a nested unit's own default, since that unit's constructor already cleared
431
- * everything under it. */
432
- function rejectInnerDefaults(node: Schema, path: string, unit: string, self: string): void {
433
- if ((node as {default?: unknown}).default !== undefined) {
434
- throw new TypeError(
435
- `a default under ${unit} never applies; give ${self} itself a whole-value ` +
436
- `default instead (found at ${path})`,
437
- )
438
- }
439
- if (node.kind === 'object') {
440
- const keys = Object.keys(node.shape)
441
- for (let i = 0; i < keys.length; i++) {
442
- rejectInnerDefaults(node.shape[keys[i]!]!, `${path}.${keys[i]!}`, unit, self)
443
- }
444
- } else if (node.kind === 'optional') {
445
- // optional() rejects an inner default itself, so this only reaches what it
446
- // wraps without reporting the same node twice.
447
- rejectInnerDefaults(node.inner, path, unit, self)
448
- }
449
- }
450
-
451
- /* Copies the annotations onto the node and rejects a `default` whose *shape*
452
- * the node would not accept, so an obviously wrong default fails where it is
453
- * written. Annotations live on the node so a schema serializes to JSON as-is.
454
- *
455
- * Constraints are deliberately not checked here, because validate() below does
456
- * not carry them: see its comment. A default that breaks its own bound is
457
- * caught by parseConfigSchema in shared.ts, which runs when the config is
458
- * packed, moments after this. */
459
- const ANNOTATION_KEYS = [
460
- 'title',
461
- 'description',
462
- 'mask',
463
- 'minLength',
464
- 'maxLength',
465
- 'min',
466
- 'max',
467
- 'integer',
468
- 'minItems',
469
- 'maxItems',
470
- 'format',
471
- 'unit',
472
- ] as const
473
-
474
- /* Every annotation any constructor accepts. Interfaces have no index
475
- * signature, so the copy below reads through a Record view of this. */
476
- type AnyOptions = DisplayOptions &
477
- Partial<
478
- Record<'mask' | 'integer', boolean> &
479
- Record<'minLength' | 'maxLength' | 'min' | 'max' | 'minItems' | 'maxItems', number> & {
480
- default: unknown
481
- }
482
- >
483
-
484
- function annotate<S extends Schema>(node: S, options?: AnyOptions): S {
485
- if (options === undefined) return node
486
- const out = node as unknown as Record<string, unknown>
487
- const src = options as Record<string, unknown>
488
- for (let i = 0; i < ANNOTATION_KEYS.length; i++) {
489
- const key = ANNOTATION_KEYS[i]!
490
- if (src[key] !== undefined) out[key] = src[key]
491
- }
492
- if (options.default !== undefined) {
493
- out.default = options.default
494
- const result = validate(node, options.default, '')
495
- if (result !== null) {
496
- throw new TypeError(`schema default does not match the schema: ${result.error.message}`)
497
- }
498
- }
499
- return node
500
- }
501
-
502
- export function string<D extends string | undefined = undefined>(
503
- options?: StringOptions<D>,
504
- ): Defaulted<StringSchema, D> {
505
- return annotate<StringSchema>({kind: 'string'}, options) as Defaulted<StringSchema, D>
506
- }
507
-
508
- export function number<D extends number | undefined = undefined>(
509
- options?: NumberOptions<D>,
510
- ): Defaulted<NumberSchema, D> {
511
- return annotate<NumberSchema>({kind: 'number'}, options) as Defaulted<NumberSchema, D>
512
- }
513
-
514
- export function boolean<D extends boolean | undefined = undefined>(
515
- options?: ScalarOptions<D>,
516
- ): Defaulted<BooleanSchema, D> {
517
- return annotate<BooleanSchema>({kind: 'boolean'}, options) as Defaulted<BooleanSchema, D>
518
- }
519
-
520
- export function unknown(): UnknownSchema {
521
- return {kind: 'unknown'}
522
- }
523
-
524
- export function literal<T extends Primitive, D extends T | undefined = undefined>(
525
- value: T,
526
- options?: ScalarOptions<D>,
527
- ): Defaulted<LiteralSchema<T>, D> {
528
- return annotate<LiteralSchema<T>>({kind: 'literal', value}, options) as Defaulted<
529
- LiteralSchema<T>,
530
- D
531
- >
532
- }
533
-
534
- export function array<S extends Schema, D extends NoInfer<Infer<S>>[] | undefined = undefined>(
535
- element: S,
536
- options?: ArrayOptions<D>,
537
- ): Defaulted<ArraySchema<S>, D> {
538
- rejectInnerDefaults(element, '[]', 'an array', 'the array')
539
- return annotate<ArraySchema<S>>({kind: 'array', element}, options) as Defaulted<ArraySchema<S>, D>
540
- }
541
-
542
- export function object<Shape extends Record<string, Schema>>(
543
- shape: Shape,
544
- options?: DefaultOption<never>,
545
- ): ObjectSchema<Shape> {
546
- if (options?.default !== undefined) {
547
- throw new TypeError(
548
- "an object's defaults compose from its fields; declare defaults on the fields",
549
- )
550
- }
551
- return annotate<ObjectSchema<Shape>>({kind: 'object', shape}, options)
552
- }
553
-
554
- export function tuple<
555
- Elements extends readonly Schema[],
556
- D extends NoInfer<Infer<TupleSchema<Elements>>> | undefined = undefined,
557
- >(elements: [...Elements], options?: DefaultOption<D>): Defaulted<TupleSchema<Elements>, D> {
558
- for (let i = 0; i < elements.length; i++) {
559
- rejectInnerDefaults(elements[i]!, `[${i}]`, 'a tuple', 'the tuple')
560
- }
561
- return annotate<TupleSchema<Elements>>({kind: 'tuple', elements}, options) as Defaulted<
562
- TupleSchema<Elements>,
563
- D
564
- >
565
- }
566
-
567
- export function optional<S extends Schema>(inner: S): OptionalSchema<S> {
568
- if ((inner as {default?: unknown}).default !== undefined) {
569
- throw new TypeError('optional() cannot wrap a schema with a default')
570
- }
571
- return {kind: 'optional', inner}
572
- }
573
-
574
- export function union<
575
- Members extends readonly Schema[],
576
- D extends NoInfer<Infer<UnionSchema<Members>>> | undefined = undefined,
577
- >(members: [...Members], options?: DefaultOption<D>): Defaulted<UnionSchema<Members>, D> {
578
- for (let i = 0; i < members.length; i++) {
579
- rejectInnerDefaults(members[i]!, `[${i}]`, 'a union', 'the union')
580
- }
581
- return annotate<UnionSchema<Members>>({kind: 'union', members}, options) as Defaulted<
582
- UnionSchema<Members>,
583
- D
584
- >
585
- }
586
-
587
- /* A closed list of values with a label for each, which is what a form renders
588
- * as a select or a radio group.
589
- *
590
- * Sugar, not a node kind: it builds a union of annotated literals, so nothing
591
- * downstream has to learn about it. parseConfigSchema already rejects an empty
592
- * union, and diffConfigSchemas already reports a removed member as requiring an
593
- * operator, which is exactly what a dropped choice is.
594
- *
595
- * Use union([literal(...)]) directly when the values need no labels; labels are
596
- * the whole point of this one. Named enumOf because `enum` is a reserved word:
597
- * an export called `enum` could not be imported under its own name. */
598
- export interface EnumEntry<T extends Primitive> {
599
- readonly value: T
600
- readonly title?: string
601
- readonly description?: string
602
- }
603
-
604
- type EnumMembers<Entries extends readonly EnumEntry<Primitive>[]> = {
605
- [K in keyof Entries]: LiteralSchema<Entries[K]['value']>
606
- }
607
-
608
- export function enumOf<
609
- const Entries extends readonly EnumEntry<Primitive>[],
610
- D extends Entries[number]['value'] | undefined = undefined,
611
- >(entries: Entries, options?: DefaultOption<D>): Defaulted<UnionSchema<EnumMembers<Entries>>, D> {
612
- const members = entries.map((entry) =>
613
- literal(entry.value, {title: entry.title, description: entry.description}),
614
- ) as unknown as EnumMembers<Entries>
615
- return annotate<UnionSchema<EnumMembers<Entries>>>(
616
- {kind: 'union', members},
617
- options,
618
- ) as Defaulted<UnionSchema<EnumMembers<Entries>>, D>
619
- }
620
-
621
- export function taggedUnion<
622
- Key extends string,
623
- Branches extends Record<string, ObjectSchema>,
624
- D extends NoInfer<Infer<TaggedUnionSchema<Key, Branches>>> | undefined = undefined,
625
- >(
626
- key: Key,
627
- branches: Branches,
628
- options?: DefaultOption<D>,
629
- ): Defaulted<TaggedUnionSchema<Key, Branches>, D> {
630
- const tags = Object.keys(branches)
631
- for (let i = 0; i < tags.length; i++) {
632
- rejectInnerDefaults(branches[tags[i]!]!, `.${tags[i]!}`, 'a taggedUnion', 'the union')
633
- }
634
- return annotate<TaggedUnionSchema<Key, Branches>>(
635
- {kind: 'taggedUnion', key, branches},
636
- options,
637
- ) as Defaulted<TaggedUnionSchema<Key, Branches>, D>
638
- }
639
-
640
- // ── Defaults ────────────────────────────────────────────────────────
641
-
642
- /* Builds the effective value: schema defaults with `value` layered over them.
643
- * Objects are structure and always materialize (recursing per field, unknown
644
- * keys dropped); every other node is replaced wholesale by a present value, so
645
- * defaults inside array elements or union branches are form hints, not fills.
646
- * The result is unvalidated — a missing required field stays missing for
647
- * parse() to report. */
648
- export function applyDefaults(schema: Schema, value: unknown): unknown {
649
- switch (schema.kind) {
650
- case 'object': {
651
- // A present non-object stays as-is so validation rejects it; replacing
652
- // it with {} here would make `{mqtt: 42}` validate clean and never
653
- // record a configError.
654
- if (
655
- value !== undefined &&
656
- (typeof value !== 'object' || value === null || Array.isArray(value))
657
- ) {
658
- return value
659
- }
660
- const src = value === undefined ? {} : (value as Record<string, unknown>)
661
- const out: Record<string, unknown> = {}
662
- const keys = Object.keys(schema.shape)
663
- for (let i = 0; i < keys.length; i++) {
664
- const key = keys[i]!
665
- const field = schema.shape[key]!
666
- // hasOwn, not indexing: a shape key like "constructor" must read as
667
- // absent, not as the inherited prototype member.
668
- const raw = Object.hasOwn(src, key) ? src[key] : undefined
669
- if (field.kind === 'optional') {
670
- if (raw !== undefined) out[key] = raw
671
- } else {
672
- const child = applyDefaults(field, raw)
673
- if (child !== undefined) out[key] = child
674
- }
675
- }
676
- return out
677
- }
678
- case 'array':
679
- if (value !== undefined) return value
680
- return schema.default !== undefined ? schema.default : []
681
- case 'unknown':
682
- case 'optional':
683
- return value
684
- default:
685
- return value !== undefined ? value : (schema as {default?: unknown}).default
686
- }
687
- }
688
-
689
- // ── Parse ───────────────────────────────────────────────────────────
690
-
691
- function typeOf(value: unknown): string {
692
- if (value === null) return 'null'
693
- if (Array.isArray(value)) return 'array'
694
- return typeof value
695
- }
696
-
697
- /* Structure only: the shape of a value, never a constraint on it.
698
- *
699
- * Constraints (min, max, integer, minLength, maxLength, minItems, maxItems,
700
- * format) are enforced host-side in shared.ts, not here. This module is bundled
701
- * into the device, and a config schema never reaches a device: it is validated
702
- * where the registry runs and where the CLI packs. Carrying the checks here
703
- * charged every app that imports mikro/schema for enforcement it could not use,
704
- * measured at about 4 KB of heap on the `+ schema` bench checkpoint, most of it
705
- * the format expressions.
706
- *
707
- * The cost of that split, stated plainly: parse() on the device checks that a
708
- * number is a number, not that it is within its declared bounds. Constraints in
709
- * a schema are a config-authoring feature. */
710
- export function validate(
711
- schema: Schema,
712
- value: unknown,
713
- path: string,
714
- ): ReturnType<typeof err<SchemaError>> | null {
715
- switch (schema.kind) {
716
- case 'string':
717
- if (typeof value !== 'string')
718
- return err(SchemaError.ValidationFailed(`expected string, got ${typeOf(value)}`, path))
719
- return null
720
-
721
- case 'number':
722
- if (typeof value !== 'number' || Number.isNaN(value))
723
- return err(SchemaError.ValidationFailed(`expected number, got ${typeOf(value)}`, path))
724
- return null
725
-
726
- case 'boolean':
727
- if (typeof value !== 'boolean')
728
- return err(SchemaError.ValidationFailed(`expected boolean, got ${typeOf(value)}`, path))
729
- return null
730
-
731
- case 'unknown':
732
- return null
733
-
734
- case 'literal':
735
- if (value !== schema.value)
736
- return err(
737
- SchemaError.ValidationFailed(
738
- `expected ${JSON.stringify(schema.value)}, got ${JSON.stringify(value)}`,
739
- path,
740
- ),
741
- )
742
- return null
743
-
744
- case 'array': {
745
- if (!Array.isArray(value))
746
- return err(SchemaError.ValidationFailed(`expected array, got ${typeOf(value)}`, path))
747
- for (let i = 0; i < value.length; i++) {
748
- const result = validate(schema.element, value[i], `${path}[${i}]`)
749
- if (result !== null) return result
750
- }
751
- return null
752
- }
753
-
754
- case 'object': {
755
- if (typeof value !== 'object' || value === null || Array.isArray(value))
756
- return err(SchemaError.ValidationFailed(`expected object, got ${typeOf(value)}`, path))
757
- const obj = value as Record<string, unknown>
758
- const keys = Object.keys(schema.shape)
759
- for (let i = 0; i < keys.length; i++) {
760
- const key = keys[i]!
761
- const fieldSchema = schema.shape[key]!
762
- const fieldPath = `${path}.${key}`
763
- // hasOwn, like the taggedUnion dispatch: an inherited "constructor"
764
- // must not stand in for a field.
765
- if (fieldSchema.kind === 'optional') {
766
- if (Object.hasOwn(obj, key)) {
767
- const result = validate(fieldSchema, obj[key], fieldPath)
768
- if (result !== null) return result
769
- }
770
- } else {
771
- if (!Object.hasOwn(obj, key))
772
- return err(SchemaError.ValidationFailed(`missing required field`, fieldPath))
773
- const result = validate(fieldSchema, obj[key], fieldPath)
774
- if (result !== null) return result
775
- }
776
- }
777
- return null
778
- }
779
-
780
- case 'tuple': {
781
- if (!Array.isArray(value))
782
- return err(SchemaError.ValidationFailed(`expected array, got ${typeOf(value)}`, path))
783
- if (value.length !== schema.elements.length)
784
- return err(
785
- SchemaError.ValidationFailed(
786
- `expected ${schema.elements.length} elements, got ${value.length}`,
787
- path,
788
- ),
789
- )
790
- for (let i = 0; i < schema.elements.length; i++) {
791
- const result = validate(schema.elements[i]!, value[i], `${path}[${i}]`)
792
- if (result !== null) return result
793
- }
794
- return null
795
- }
796
-
797
- case 'optional': {
798
- if (value === undefined) return null
799
- return validate(schema.inner, value, path)
800
- }
801
-
802
- case 'union': {
803
- for (let i = 0; i < schema.members.length; i++) {
804
- const result = validate(schema.members[i]!, value, path)
805
- if (result === null) return null
806
- }
807
- return err(SchemaError.ValidationFailed(`value did not match any union member`, path))
808
- }
809
-
810
- case 'taggedUnion': {
811
- if (typeof value !== 'object' || value === null || Array.isArray(value))
812
- return err(SchemaError.ValidationFailed(`expected object, got ${typeOf(value)}`, path))
813
- const obj = value as Record<string, unknown>
814
- const tag = obj[schema.key]
815
- if (tag === undefined)
816
- return err(
817
- SchemaError.ValidationFailed(`missing discriminator field`, `${path}.${schema.key}`),
818
- )
819
- if (typeof tag !== 'string' && typeof tag !== 'number' && typeof tag !== 'boolean')
820
- return err(
821
- SchemaError.ValidationFailed(
822
- `expected primitive discriminator, got ${typeOf(tag)}`,
823
- `${path}.${schema.key}`,
824
- ),
825
- )
826
- // hasOwn, not indexing: a tag like "constructor" must not resolve to
827
- // an inherited property and validate against garbage.
828
- const branch = Object.hasOwn(schema.branches, tag as string)
829
- ? schema.branches[tag as string]
830
- : undefined
831
- if (branch === undefined)
832
- return err(
833
- SchemaError.ValidationFailed(
834
- `unknown tag ${JSON.stringify(tag)}`,
835
- `${path}.${schema.key}`,
836
- ),
837
- )
838
- return validate(branch, value, path)
839
- }
840
- }
841
- return err(SchemaError.ValidationFailed(`unknown schema kind: ${(schema as any).kind}`, path))
842
- }