@mikrojs/native 0.18.2 → 0.18.3-next.20260829153835

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.
@@ -7,32 +7,208 @@ function err<E>(error: E) {
7
7
  return {ok: false as const, error}
8
8
  }
9
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}
10
16
  export const SchemaError = {
11
- ValidationFailed: (message: string, path: string) =>
12
- ({name: 'ValidationFailed', message, path}) as const,
17
+ ValidationFailed: (message: string, path: string): SchemaError => ({
18
+ name: 'ValidationFailed',
19
+ message,
20
+ path,
21
+ }),
13
22
  }
14
- export type SchemaError = ReturnType<typeof SchemaError.ValidationFailed>
15
23
 
16
24
  // ── Schema types ────────────────────────────────────────────────────
17
25
 
18
26
  type Primitive = string | number | boolean
19
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
+
20
178
  /* The `default` annotation is stored as an extra node property so a schema
21
179
  * serializes to JSON as-is; it is typed precisely on the constructor options
22
180
  * and loosely on the node, which keeps Infer free of recursive
23
181
  * instantiations. */
24
182
 
25
- export interface StringSchema {
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 {
26
193
  readonly kind: 'string'
27
194
  readonly default?: string
195
+ readonly mask?: boolean
196
+ readonly minLength?: number
197
+ readonly maxLength?: number
198
+ readonly format?: Format
28
199
  }
29
200
 
30
- export interface NumberSchema {
201
+ export interface NumberSchema extends Annotated {
31
202
  readonly kind: 'number'
32
203
  readonly default?: number
204
+ readonly mask?: boolean
205
+ readonly min?: number
206
+ readonly max?: number
207
+ readonly integer?: boolean
208
+ readonly unit?: Unit
33
209
  }
34
210
 
35
- export interface BooleanSchema {
211
+ export interface BooleanSchema extends Annotated {
36
212
  readonly kind: 'boolean'
37
213
  readonly default?: boolean
38
214
  }
@@ -41,19 +217,23 @@ export interface UnknownSchema {
41
217
  readonly kind: 'unknown'
42
218
  }
43
219
 
44
- export interface LiteralSchema<T extends Primitive = Primitive> {
220
+ export interface LiteralSchema<T extends Primitive = Primitive> extends Annotated {
45
221
  readonly kind: 'literal'
46
222
  readonly value: T
47
223
  readonly default?: T
48
224
  }
49
225
 
50
- export interface ArraySchema<S extends Schema = Schema> {
226
+ export interface ArraySchema<S extends Schema = Schema> extends Annotated {
51
227
  readonly kind: 'array'
52
228
  readonly element: S
53
229
  readonly default?: unknown
230
+ readonly minItems?: number
231
+ readonly maxItems?: number
54
232
  }
55
233
 
56
- export interface ObjectSchema<Shape extends Record<string, Schema> = Record<string, Schema>> {
234
+ export interface ObjectSchema<
235
+ Shape extends Record<string, Schema> = Record<string, Schema>,
236
+ > extends Annotated {
57
237
  readonly kind: 'object'
58
238
  readonly shape: Shape
59
239
  }
@@ -63,13 +243,17 @@ export interface OptionalSchema<S extends Schema = Schema> {
63
243
  readonly inner: S
64
244
  }
65
245
 
66
- export interface TupleSchema<Elements extends readonly Schema[] = readonly Schema[]> {
246
+ export interface TupleSchema<
247
+ Elements extends readonly Schema[] = readonly Schema[],
248
+ > extends Annotated {
67
249
  readonly kind: 'tuple'
68
250
  readonly elements: Elements
69
251
  readonly default?: unknown
70
252
  }
71
253
 
72
- export interface UnionSchema<Members extends readonly Schema[] = readonly Schema[]> {
254
+ export interface UnionSchema<
255
+ Members extends readonly Schema[] = readonly Schema[],
256
+ > extends Annotated {
73
257
  readonly kind: 'union'
74
258
  readonly members: Members
75
259
  readonly default?: unknown
@@ -78,7 +262,7 @@ export interface UnionSchema<Members extends readonly Schema[] = readonly Schema
78
262
  export interface TaggedUnionSchema<
79
263
  Key extends string = string,
80
264
  Branches extends Record<string, ObjectSchema> = Record<string, ObjectSchema>,
81
- > {
265
+ > extends Annotated {
82
266
  readonly kind: 'taggedUnion'
83
267
  readonly key: Key
84
268
  readonly branches: Branches
@@ -190,14 +374,50 @@ type InferReadObject<Shape> = {
190
374
 
191
375
  // ── Schema constructors ─────────────────────────────────────────────
192
376
 
193
- export interface ScalarOptions<T> {
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 {
194
385
  readonly default?: T
195
386
  }
196
387
 
197
- export interface DefaultOption<T> {
388
+ export interface DefaultOption<T> extends DisplayOptions {
198
389
  readonly default?: T
199
390
  }
200
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
+
201
421
  /* A node interface types `default` as optional, so a defaulted node and a bare
202
422
  * one are the same type; the constructors record the annotation in their
203
423
  * return type instead, which is what lets InferRead see it. D is the inferred
@@ -228,11 +448,47 @@ function rejectInnerDefaults(node: Schema, path: string, unit: string, self: str
228
448
  }
229
449
  }
230
450
 
231
- /* Copies the annotation onto the node and rejects a `default` the node itself
232
- * would not accept, so a bad default fails where it is written. */
233
- function annotate<S extends Schema>(node: S, options?: {default?: unknown}): S {
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 {
234
485
  if (options === undefined) return node
235
- const out = node as {default?: unknown}
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
+ }
236
492
  if (options.default !== undefined) {
237
493
  out.default = options.default
238
494
  const result = validate(node, options.default, '')
@@ -244,13 +500,13 @@ function annotate<S extends Schema>(node: S, options?: {default?: unknown}): S {
244
500
  }
245
501
 
246
502
  export function string<D extends string | undefined = undefined>(
247
- options?: ScalarOptions<D>,
503
+ options?: StringOptions<D>,
248
504
  ): Defaulted<StringSchema, D> {
249
505
  return annotate<StringSchema>({kind: 'string'}, options) as Defaulted<StringSchema, D>
250
506
  }
251
507
 
252
508
  export function number<D extends number | undefined = undefined>(
253
- options?: ScalarOptions<D>,
509
+ options?: NumberOptions<D>,
254
510
  ): Defaulted<NumberSchema, D> {
255
511
  return annotate<NumberSchema>({kind: 'number'}, options) as Defaulted<NumberSchema, D>
256
512
  }
@@ -277,7 +533,7 @@ export function literal<T extends Primitive, D extends T | undefined = undefined
277
533
 
278
534
  export function array<S extends Schema, D extends NoInfer<Infer<S>>[] | undefined = undefined>(
279
535
  element: S,
280
- options?: DefaultOption<D>,
536
+ options?: ArrayOptions<D>,
281
537
  ): Defaulted<ArraySchema<S>, D> {
282
538
  rejectInnerDefaults(element, '[]', 'an array', 'the array')
283
539
  return annotate<ArraySchema<S>>({kind: 'array', element}, options) as Defaulted<ArraySchema<S>, D>
@@ -292,7 +548,7 @@ export function object<Shape extends Record<string, Schema>>(
292
548
  "an object's defaults compose from its fields; declare defaults on the fields",
293
549
  )
294
550
  }
295
- return {kind: 'object', shape}
551
+ return annotate<ObjectSchema<Shape>>({kind: 'object', shape}, options)
296
552
  }
297
553
 
298
554
  export function tuple<
@@ -328,6 +584,40 @@ export function union<
328
584
  >
329
585
  }
330
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
+
331
621
  export function taggedUnion<
332
622
  Key extends string,
333
623
  Branches extends Record<string, ObjectSchema>,
@@ -404,6 +694,19 @@ function typeOf(value: unknown): string {
404
694
  return typeof value
405
695
  }
406
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. */
407
710
  export function validate(
408
711
  schema: Schema,
409
712
  value: unknown,
@@ -4,27 +4,36 @@ import type {Result} from '../result/types.js'
4
4
  import {type Infer, type Schema, type SchemaError, validate} from './core.js'
5
5
 
6
6
  export type {
7
+ ArrayOptions,
7
8
  ArraySchema,
8
9
  BooleanSchema,
9
10
  DefaultOption,
11
+ DisplayOptions,
12
+ EnumEntry,
13
+ Format,
10
14
  Infer,
11
15
  InferRead,
12
16
  LiteralSchema,
17
+ MaskableOptions,
18
+ NumberOptions,
13
19
  NumberSchema,
14
20
  ObjectSchema,
15
21
  OptionalSchema,
16
22
  ScalarOptions,
17
23
  Schema,
24
+ StringOptions,
18
25
  StringSchema,
19
26
  TaggedUnionSchema,
20
27
  TupleSchema,
21
28
  UnionSchema,
29
+ Unit,
22
30
  UnknownSchema,
23
31
  } from './core.js'
24
32
  export {
25
33
  applyDefaults,
26
34
  array,
27
35
  boolean,
36
+ enumOf,
28
37
  literal,
29
38
  number,
30
39
  object,