@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.
@@ -4,12 +4,299 @@
4
4
  * overlay. Imports core.ts only, so hosts load it without resolving mikro/*
5
5
  * builtins; results are plain {ok} shapes for the same reason. */
6
6
 
7
- import {applyDefaults, type ObjectSchema, type Schema, SchemaError, validate} from './core.js'
7
+ import {
8
+ applyDefaults,
9
+ type Format,
10
+ type ObjectSchema,
11
+ type Schema,
12
+ SchemaError,
13
+ type Unit,
14
+ validate,
15
+ } from './core.js'
8
16
 
9
17
  export type SchemaCheck<T> = {ok: true; value: T} | {ok: false; error: SchemaError}
10
18
 
19
+ /* The format expressions live here, not in core.ts, because core.ts is bundled
20
+ * into the device and a config schema is never validated there. Not a
21
+ * caller-supplied `pattern`: a registry runs these against operator input, so a
22
+ * publisher-supplied regular expression would be a denial-of-service vector. */
23
+ const FORMAT_PATTERNS: Record<Format, RegExp> = {
24
+ url: /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\/[^\s/?#]+\S*$/,
25
+ hostname:
26
+ /^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/,
27
+ ipv4: /^(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}$/,
28
+ // Separators do not mix: aa:bb-cc:dd:ee:ff is not an address.
29
+ mac: /^([0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}$|^([0-9a-fA-F]{2}-){5}[0-9a-fA-F]{2}$/,
30
+ email: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
31
+ }
32
+
33
+ export const FORMATS = Object.keys(FORMAT_PATTERNS) as readonly Format[]
34
+
35
+ /**
36
+ * Validates a value against a config schema, constraints included. `validate()`
37
+ * in core.ts checks structure only, because it ships to the device and a config
38
+ * schema never does; every host-side path that validates an operator's value
39
+ * goes through this instead.
40
+ */
41
+ export function validateConfig(schema: Schema, value: unknown): SchemaCheck<unknown> {
42
+ const structural = validate(schema, value, '')
43
+ if (structural !== null) return {ok: false, error: structural.error}
44
+ const constraint = checkValueConstraints(schema, value, '')
45
+ if (constraint !== null) return constraint
46
+ return {ok: true, value}
47
+ }
48
+
49
+ /* Mirrors validate()'s walk, applying only the constraint checks. Runs after
50
+ * the structural pass, so every value here is already the right shape. */
51
+ function checkValueConstraints(
52
+ schema: Schema,
53
+ value: unknown,
54
+ path: string,
55
+ ): ReturnType<typeof fail> | null {
56
+ switch (schema.kind) {
57
+ case 'string': {
58
+ const text = value as string
59
+ const {minLength, maxLength, format} = schema
60
+ if (minLength !== undefined && text.length < minLength) {
61
+ return fail(`shorter than ${minLength} characters`, path)
62
+ }
63
+ if (maxLength !== undefined && text.length > maxLength) {
64
+ return fail(`longer than ${maxLength} characters`, path)
65
+ }
66
+ if (format !== undefined && !FORMAT_PATTERNS[format].test(text)) {
67
+ return fail(`not a valid ${format}`, path)
68
+ }
69
+ // The pattern bounds each label at 63 characters; the whole name has its
70
+ // own limit that no per-label rule can express.
71
+ if (format === 'hostname' && text.length > 253) {
72
+ return fail('not a valid hostname', path)
73
+ }
74
+ return null
75
+ }
76
+ case 'number': {
77
+ const num = value as number
78
+ const {min, max} = schema
79
+ if (schema.integer === true && !Number.isInteger(num)) {
80
+ return fail(`expected a whole number, got ${num}`, path)
81
+ }
82
+ if (min !== undefined && num < min) return fail(`below the minimum of ${min}`, path)
83
+ if (max !== undefined && num > max) return fail(`above the maximum of ${max}`, path)
84
+ return null
85
+ }
86
+ case 'array': {
87
+ const items = value as unknown[]
88
+ const {minItems, maxItems} = schema
89
+ if (minItems !== undefined && items.length < minItems) {
90
+ return fail(`fewer than ${minItems} items`, path)
91
+ }
92
+ if (maxItems !== undefined && items.length > maxItems) {
93
+ return fail(`more than ${maxItems} items`, path)
94
+ }
95
+ for (let i = 0; i < items.length; i++) {
96
+ const result = checkValueConstraints(schema.element, items[i], `${path}[${i}]`)
97
+ if (result !== null) return result
98
+ }
99
+ return null
100
+ }
101
+ case 'object': {
102
+ const obj = value as Record<string, unknown>
103
+ for (const key of Object.keys(schema.shape)) {
104
+ if (!Object.hasOwn(obj, key)) continue
105
+ const result = checkValueConstraints(schema.shape[key]!, obj[key], `${path}.${key}`)
106
+ if (result !== null) return result
107
+ }
108
+ return null
109
+ }
110
+ case 'optional':
111
+ return value === undefined ? null : checkValueConstraints(schema.inner, value, path)
112
+ case 'tuple': {
113
+ const items = value as unknown[]
114
+ for (let i = 0; i < schema.elements.length; i++) {
115
+ const result = checkValueConstraints(schema.elements[i]!, items[i], `${path}[${i}]`)
116
+ if (result !== null) return result
117
+ }
118
+ return null
119
+ }
120
+ case 'union': {
121
+ /* A union accepts what ANY member accepts, so the constraint pass has to
122
+ * agree with the structural one. Applying only the first structurally
123
+ * matching member's constraints would reject a value a later member
124
+ * allows: in union([number({max: 10}), number({min: 100})]), 150 matches
125
+ * the first member's shape, fails its bound, and would be refused even
126
+ * though the second member exists for exactly that value.
127
+ *
128
+ * When nothing passes, report the first member's constraint failure
129
+ * rather than a generic "no member matched": for the ordinary union whose
130
+ * members differ in shape, that is the specific and useful message. */
131
+ let firstFailure: ReturnType<typeof fail> | null = null
132
+ for (const member of schema.members) {
133
+ if (validate(member, value, '') !== null) continue
134
+ const result = checkValueConstraints(member, value, path)
135
+ if (result === null) return null
136
+ firstFailure ??= result
137
+ }
138
+ return firstFailure
139
+ }
140
+ case 'taggedUnion': {
141
+ const obj = value as Record<string, unknown>
142
+ const branch = schema.branches[obj[schema.key] as string]
143
+ return branch === undefined ? null : checkValueConstraints(branch, value, path)
144
+ }
145
+ default:
146
+ return null
147
+ }
148
+ }
149
+
150
+ /** How a unit relates to the primary it measures in, plus the symbol to render
151
+ * when the ASCII key is not what a person should read.
152
+ *
153
+ * `scale` and `offset` exist so a form can show a read-only hint beside a
154
+ * field ("30000000 us (30 s)"). They MUST NOT be used to convert a stored
155
+ * value. The registry stores only deviations from the schema defaults and
156
+ * hashes the effective document for its rev, so a lossy round trip stops a
157
+ * default-equal value being stripped and two operators entering the same
158
+ * thing produce different revs. Having the numbers here makes converting look
159
+ * easy; it is still wrong. */
160
+ export interface UnitDefinition {
161
+ readonly primary: Unit
162
+ readonly scale: number
163
+ readonly offset: number
164
+ /* What to render beside the number, when the ASCII key is not it. Absent
165
+ * means the key is already the right symbol. An EMPTY string means render no
166
+ * suffix at all: the dimensionless units are named `/` and `count` in the
167
+ * registry, and "0.8 /" is not something to show an operator. */
168
+ readonly symbol?: string
169
+ }
170
+
171
+ /* Declared as Record<Unit, ...> on purpose: the compiler then refuses a table
172
+ * that is missing a member of the union or carries one that is not in it, so
173
+ * the names are declared once in core.ts and cannot drift from this. */
174
+ export const UNITS: Record<Unit, UnitDefinition> = {
175
+ m: {primary: 'm', scale: 1, offset: 0},
176
+ kg: {primary: 'kg', scale: 1, offset: 0},
177
+ s: {primary: 's', scale: 1, offset: 0},
178
+ A: {primary: 'A', scale: 1, offset: 0},
179
+ K: {primary: 'K', scale: 1, offset: 0},
180
+ cd: {primary: 'cd', scale: 1, offset: 0},
181
+ mol: {primary: 'mol', scale: 1, offset: 0},
182
+ Hz: {primary: 'Hz', scale: 1, offset: 0},
183
+ rad: {primary: 'rad', scale: 1, offset: 0},
184
+ sr: {primary: 'sr', scale: 1, offset: 0},
185
+ N: {primary: 'N', scale: 1, offset: 0},
186
+ Pa: {primary: 'Pa', scale: 1, offset: 0},
187
+ J: {primary: 'J', scale: 1, offset: 0},
188
+ W: {primary: 'W', scale: 1, offset: 0},
189
+ C: {primary: 'C', scale: 1, offset: 0},
190
+ V: {primary: 'V', scale: 1, offset: 0},
191
+ F: {primary: 'F', scale: 1, offset: 0},
192
+ Ohm: {primary: 'Ohm', scale: 1, offset: 0, symbol: 'Ω'},
193
+ S: {primary: 'S', scale: 1, offset: 0},
194
+ Wb: {primary: 'Wb', scale: 1, offset: 0},
195
+ T: {primary: 'T', scale: 1, offset: 0},
196
+ H: {primary: 'H', scale: 1, offset: 0},
197
+ Cel: {primary: 'Cel', scale: 1, offset: 0, symbol: '°C'},
198
+ lm: {primary: 'lm', scale: 1, offset: 0},
199
+ lx: {primary: 'lx', scale: 1, offset: 0},
200
+ Bq: {primary: 'Bq', scale: 1, offset: 0},
201
+ Gy: {primary: 'Gy', scale: 1, offset: 0},
202
+ Sv: {primary: 'Sv', scale: 1, offset: 0},
203
+ kat: {primary: 'kat', scale: 1, offset: 0},
204
+ m2: {primary: 'm2', scale: 1, offset: 0, symbol: 'm²'},
205
+ m3: {primary: 'm3', scale: 1, offset: 0, symbol: 'm³'},
206
+ 'm/s': {primary: 'm/s', scale: 1, offset: 0},
207
+ 'm/s2': {primary: 'm/s2', scale: 1, offset: 0, symbol: 'm/s²'},
208
+ 'm3/s': {primary: 'm3/s', scale: 1, offset: 0, symbol: 'm³/s'},
209
+ 'W/m2': {primary: 'W/m2', scale: 1, offset: 0, symbol: 'W/m²'},
210
+ 'cd/m2': {primary: 'cd/m2', scale: 1, offset: 0, symbol: 'cd/m²'},
211
+ bit: {primary: 'bit', scale: 1, offset: 0},
212
+ 'bit/s': {primary: 'bit/s', scale: 1, offset: 0},
213
+ lat: {primary: 'lat', scale: 1, offset: 0},
214
+ lon: {primary: 'lon', scale: 1, offset: 0},
215
+ pH: {primary: 'pH', scale: 1, offset: 0},
216
+ dB: {primary: 'dB', scale: 1, offset: 0},
217
+ dBW: {primary: 'dBW', scale: 1, offset: 0},
218
+ count: {primary: 'count', scale: 1, offset: 0, symbol: ''},
219
+ '/': {primary: '/', scale: 1, offset: 0, symbol: ''},
220
+ '%RH': {primary: '%RH', scale: 1, offset: 0},
221
+ '%EL': {primary: '%EL', scale: 1, offset: 0},
222
+ EL: {primary: 'EL', scale: 1, offset: 0},
223
+ '1/s': {primary: '1/s', scale: 1, offset: 0},
224
+ 'S/m': {primary: 'S/m', scale: 1, offset: 0},
225
+ B: {primary: 'B', scale: 1, offset: 0},
226
+ VA: {primary: 'VA', scale: 1, offset: 0},
227
+ VAs: {primary: 'VAs', scale: 1, offset: 0},
228
+ var: {primary: 'var', scale: 1, offset: 0},
229
+ vars: {primary: 'vars', scale: 1, offset: 0},
230
+ 'J/m': {primary: 'J/m', scale: 1, offset: 0},
231
+ 'kg/m3': {primary: 'kg/m3', scale: 1, offset: 0, symbol: 'kg/m³'},
232
+ deg: {primary: 'deg', scale: 1, offset: 0, symbol: '°'},
233
+ NTU: {primary: 'NTU', scale: 1, offset: 0},
234
+ ms: {primary: 's', scale: 1 / 1000, offset: 0},
235
+ min: {primary: 's', scale: 60, offset: 0},
236
+ h: {primary: 's', scale: 3600, offset: 0},
237
+ MHz: {primary: 'Hz', scale: 1000000, offset: 0},
238
+ kW: {primary: 'W', scale: 1000, offset: 0},
239
+ kVA: {primary: 'VA', scale: 1000, offset: 0},
240
+ kvar: {primary: 'var', scale: 1000, offset: 0},
241
+ Ah: {primary: 'C', scale: 3600, offset: 0},
242
+ Wh: {primary: 'J', scale: 3600, offset: 0},
243
+ kWh: {primary: 'J', scale: 3600000, offset: 0},
244
+ varh: {primary: 'vars', scale: 3600, offset: 0},
245
+ kvarh: {primary: 'vars', scale: 3600000, offset: 0},
246
+ kVAh: {primary: 'VAs', scale: 3600000, offset: 0},
247
+ 'Wh/km': {primary: 'J/m', scale: 3.6, offset: 0},
248
+ KiB: {primary: 'B', scale: 1024, offset: 0},
249
+ GB: {primary: 'B', scale: 1e9, offset: 0},
250
+ 'Mbit/s': {primary: 'bit/s', scale: 1000000, offset: 0},
251
+ 'B/s': {primary: 'bit/s', scale: 8, offset: 0},
252
+ 'MB/s': {primary: 'bit/s', scale: 8000000, offset: 0},
253
+ mV: {primary: 'V', scale: 1 / 1000, offset: 0},
254
+ mA: {primary: 'A', scale: 1 / 1000, offset: 0},
255
+ dBm: {primary: 'dBW', scale: 1, offset: -30},
256
+ 'ug/m3': {primary: 'kg/m3', scale: 1e-9, offset: 0, symbol: 'µg/m³'},
257
+ 'mm/h': {primary: 'm/s', scale: 1 / 3600000, offset: 0},
258
+ 'm/h': {primary: 'm/s', scale: 1 / 3600, offset: 0},
259
+ ppm: {primary: '/', scale: 1e-6, offset: 0},
260
+ '/100': {primary: '/', scale: 1 / 100, offset: 0, symbol: '%'},
261
+ '/1000': {primary: '/', scale: 1 / 1000, offset: 0, symbol: '‰'},
262
+ hPa: {primary: 'Pa', scale: 100, offset: 0},
263
+ mm: {primary: 'm', scale: 1 / 1000, offset: 0},
264
+ cm: {primary: 'm', scale: 1 / 100, offset: 0},
265
+ km: {primary: 'm', scale: 1000, offset: 0},
266
+ 'km/h': {primary: 'm/s', scale: 1 / 3.6, offset: 0},
267
+ ppb: {primary: '/', scale: 1e-9, offset: 0},
268
+ ppt: {primary: '/', scale: 1e-12, offset: 0},
269
+ VAh: {primary: 'VAs', scale: 3600, offset: 0},
270
+ 'mg/l': {primary: 'kg/m3', scale: 1 / 1000, offset: 0},
271
+ 'ug/l': {primary: 'kg/m3', scale: 1e-6, offset: 0, symbol: 'µg/l'},
272
+ 'g/l': {primary: 'kg/m3', scale: 1, offset: 0},
273
+ us: {primary: 's', scale: 1 / 1000000, offset: 0, symbol: 'µs'},
274
+ kHz: {primary: 'Hz', scale: 1000, offset: 0},
275
+ GHz: {primary: 'Hz', scale: 1000000000, offset: 0},
276
+ mW: {primary: 'W', scale: 1 / 1000, offset: 0},
277
+ uA: {primary: 'A', scale: 1 / 1000000, offset: 0, symbol: 'µA'},
278
+ uV: {primary: 'V', scale: 1 / 1000000, offset: 0, symbol: 'µV'},
279
+ mAh: {primary: 'C', scale: 3.6, offset: 0},
280
+ MiB: {primary: 'B', scale: 1048576, offset: 0},
281
+ kB: {primary: 'B', scale: 1000, offset: 0},
282
+ MB: {primary: 'B', scale: 1000000, offset: 0},
283
+ 'kbit/s': {primary: 'bit/s', scale: 1000, offset: 0},
284
+ 'KiB/s': {primary: 'bit/s', scale: 8192, offset: 0},
285
+ kohm: {primary: 'Ohm', scale: 1000, offset: 0, symbol: 'kΩ'},
286
+ Mohm: {primary: 'Ohm', scale: 1000000, offset: 0, symbol: 'MΩ'},
287
+ kPa: {primary: 'Pa', scale: 1000, offset: 0},
288
+ bar: {primary: 'Pa', scale: 100000, offset: 0},
289
+ Bd: {primary: '1/s', scale: 1, offset: 0},
290
+ }
291
+
11
292
  const MAX_DEPTH = 8
12
293
 
294
+ /* Caps on operator-visible annotation strings. A title is a field label and a
295
+ * description a sentence or two; both count toward the caller's encoded-size
296
+ * cap, so bound them here rather than letting one field crowd out a schema. */
297
+ const MAX_TITLE_LENGTH = 80
298
+ const MAX_DESCRIPTION_LENGTH = 500
299
+
13
300
  const KINDS = new Set([
14
301
  'string',
15
302
  'number',
@@ -190,15 +477,124 @@ function walk(value: unknown, path: string, depth: number): ReturnType<typeof fa
190
477
  }
191
478
  }
192
479
 
480
+ const annotations = checkAnnotations(node, kind, path)
481
+ if (annotations !== null) return annotations
482
+
193
483
  if (node.default !== undefined) {
194
- const check = validate(node as unknown as Schema, node.default, '')
195
- if (check !== null) {
484
+ // Constraint-aware on purpose: the constructors cannot do this any more,
485
+ // since core.ts no longer carries the checks, so a default that breaks its
486
+ // own bound must be caught here, at pack, which is moments later.
487
+ const check = validateConfig(node as unknown as Schema, node.default)
488
+ if (!check.ok) {
196
489
  return fail(`default does not match the schema: ${check.error.message}`, path)
197
490
  }
198
491
  }
199
492
  return null
200
493
  }
201
494
 
495
+ /* The constructors' own TypeErrors never run against a JSON-sourced AST, so
496
+ * every annotation the constructors accept is re-checked here. optional() is
497
+ * the wrapper the annotations do not belong on: it expresses absence, the node
498
+ * it wraps expresses identity. */
499
+ function checkAnnotations(
500
+ node: Record<string, unknown>,
501
+ kind: string,
502
+ path: string,
503
+ ): ReturnType<typeof fail> | null {
504
+ const onWrapper = kind === 'optional' || kind === 'unknown'
505
+ const text = (key: 'title' | 'description', max: number) => {
506
+ const value = node[key]
507
+ if (value === undefined) return null
508
+ if (onWrapper) return fail(`${kind}() cannot carry a ${key}; annotate what it wraps`, path)
509
+ if (typeof value !== 'string') return fail(`${key} must be a string`, path)
510
+ if (value.length === 0) return fail(`${key} must not be empty; omit it instead`, path)
511
+ if (value.length > max) return fail(`${key} is longer than ${max} characters`, path)
512
+ return null
513
+ }
514
+ const title = text('title', MAX_TITLE_LENGTH)
515
+ if (title !== null) return title
516
+ const description = text('description', MAX_DESCRIPTION_LENGTH)
517
+ if (description !== null) return description
518
+
519
+ if (node.mask !== undefined) {
520
+ if (kind !== 'string' && kind !== 'number') {
521
+ return fail(`mask is only allowed on string() and number()`, path)
522
+ }
523
+ if (typeof node.mask !== 'boolean') return fail('mask must be a boolean', path)
524
+ if (node.mask === true && node.default !== undefined) {
525
+ // A masked field with a default ships the same placeholder credential to
526
+ // every device, which is the opposite of what masking is for. Only when
527
+ // it is actually masked: `mask: false` is the ordinary state and says
528
+ // nothing about defaults.
529
+ return fail('a masked field cannot carry a default', path)
530
+ }
531
+ }
532
+ return checkConstraints(node, kind, path)
533
+ }
534
+
535
+ /* Which constraints each kind accepts, and whether the value must be a
536
+ * non-negative whole number (a count) or merely finite (a bound). */
537
+ const CONSTRAINTS_BY_KIND: Record<string, readonly [string, string]> = {
538
+ string: ['minLength', 'maxLength'],
539
+ number: ['min', 'max'],
540
+ array: ['minItems', 'maxItems'],
541
+ }
542
+ const COUNT_CONSTRAINTS = ['minLength', 'maxLength', 'minItems', 'maxItems']
543
+ const ALL_CONSTRAINTS = ['minLength', 'maxLength', 'min', 'max', 'minItems', 'maxItems']
544
+
545
+ function checkConstraints(
546
+ node: Record<string, unknown>,
547
+ kind: string,
548
+ path: string,
549
+ ): ReturnType<typeof fail> | null {
550
+ const allowed = CONSTRAINTS_BY_KIND[kind]
551
+ for (const key of ALL_CONSTRAINTS) {
552
+ const value = node[key]
553
+ if (value === undefined) continue
554
+ if (allowed === undefined || !allowed.includes(key)) {
555
+ return fail(`${key} is not allowed on ${kind}()`, path)
556
+ }
557
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
558
+ return fail(`${key} must be a finite number`, path)
559
+ }
560
+ if (COUNT_CONSTRAINTS.includes(key) && (!Number.isInteger(value) || value < 0)) {
561
+ return fail(`${key} must be a non-negative whole number`, path)
562
+ }
563
+ }
564
+ if (allowed !== undefined) {
565
+ const [lower, upper] = allowed
566
+ const low = node[lower]
567
+ const high = node[upper]
568
+ if (typeof low === 'number' && typeof high === 'number' && low > high) {
569
+ return fail(`${lower} is greater than ${upper}`, path)
570
+ }
571
+ }
572
+ if (node.integer !== undefined) {
573
+ if (kind !== 'number') return fail('integer is not allowed on ' + kind + '()', path)
574
+ if (typeof node.integer !== 'boolean') return fail('integer must be a boolean', path)
575
+ }
576
+ if (node.unit !== undefined) {
577
+ if (kind !== 'number') return fail(`unit is not allowed on ${kind}()`, path)
578
+ if (typeof node.unit !== 'string' || !Object.hasOwn(UNITS, node.unit)) {
579
+ return fail(`unknown unit ${JSON.stringify(node.unit)}`, path)
580
+ }
581
+ }
582
+ if (node.format !== undefined) {
583
+ if (kind !== 'string') return fail(`format is not allowed on ${kind}()`, path)
584
+ // Fail closed. An unrecognised display annotation may be ignored; an
585
+ // unrecognised constraint may not, since ignoring it means accepting a
586
+ // value the author ruled out. Rejecting at publish puts it in front of the
587
+ // one person who can fix it.
588
+ if (typeof node.format !== 'string' || !FORMATS.includes(node.format as never)) {
589
+ return fail(
590
+ `unknown format ${JSON.stringify(node.format)} (known: ${FORMATS.join(', ')})`,
591
+ path,
592
+ )
593
+ }
594
+ }
595
+ return null
596
+ }
597
+
202
598
  /**
203
599
  * Derives the overlay to store or serve from operator-supplied values: drops
204
600
  * keys the schema does not know, strips values structurally equal to the
@@ -290,8 +686,10 @@ export function structuralEquals(a: unknown, b: unknown): boolean {
290
686
  */
291
687
  export function parseEffective(schema: Schema, overlay: unknown): SchemaCheck<unknown> {
292
688
  const effective = applyDefaults(schema, overlay)
293
- const result = validate(schema, effective, '')
294
- return result !== null ? result : {ok: true, value: effective}
689
+ // validateConfig, not validate: this is the gate an operator's config passes
690
+ // through, and constraints only bind if they are checked here.
691
+ const result = validateConfig(schema, effective)
692
+ return result.ok ? {ok: true, value: effective} : result
295
693
  }
296
694
 
297
695
  /**
@@ -338,21 +736,45 @@ function fillField(node: Schema): {value: unknown} | undefined {
338
736
 
339
737
  /** A node with its annotations removed, recursively, so two schemas can be
340
738
  * compared on structure alone. */
341
- function stripAnnotations(node: unknown): unknown {
739
+ /* Cosmetic: a change to one of these must never read as a structural change. */
740
+ const DISPLAY_KEYS = ['default', 'title', 'description', 'mask']
741
+
742
+ /* Semantic, so they survive stripAnnotations and are reported on their own
743
+ * terms. Stripped only for the type comparison, where a tightened bound must
744
+ * not masquerade as a changed type. */
745
+ const CONSTRAINT_KEYS = [
746
+ 'minLength',
747
+ 'maxLength',
748
+ 'min',
749
+ 'max',
750
+ 'integer',
751
+ 'minItems',
752
+ 'maxItems',
753
+ 'format',
754
+ ]
755
+
756
+ /* `unit` renders as a suffix, but it is not cosmetic: it reinterprets every
757
+ * stored value, since `interval: 30` means one thing under `s` and another
758
+ * under `ms`. Nothing fails validation, the device just behaves differently. So
759
+ * it survives stripAnnotations and is reported on its own terms, and is
760
+ * stripped only for the type comparison. */
761
+ const SHAPE_KEYS = [...DISPLAY_KEYS, ...CONSTRAINT_KEYS, 'unit']
762
+
763
+ function stripKeys(node: unknown, keys: readonly string[]): unknown {
342
764
  if (!isPlainObject(node)) return node
343
- const {default: _default, ...rest} = node
344
765
  const out: Record<string, unknown> = {}
345
- for (const key of Object.keys(rest)) {
346
- const value = rest[key]
766
+ for (const key of Object.keys(node)) {
767
+ if (keys.includes(key)) continue
768
+ const value = node[key]
347
769
  if (key === 'shape' || key === 'branches') {
348
770
  const map = value as Record<string, unknown>
349
771
  const stripped: Record<string, unknown> = {}
350
- for (const k of Object.keys(map)) stripped[k] = stripAnnotations(map[k])
772
+ for (const k of Object.keys(map)) stripped[k] = stripKeys(map[k], keys)
351
773
  out[key] = stripped
352
774
  } else if (key === 'element' || key === 'inner') {
353
- out[key] = stripAnnotations(value)
775
+ out[key] = stripKeys(value, keys)
354
776
  } else if (key === 'elements' || key === 'members') {
355
- out[key] = (value as unknown[]).map(stripAnnotations)
777
+ out[key] = (value as unknown[]).map((item) => stripKeys(item, keys))
356
778
  } else {
357
779
  out[key] = value
358
780
  }
@@ -360,6 +782,100 @@ function stripAnnotations(node: unknown): unknown {
360
782
  return out
361
783
  }
362
784
 
785
+ /** A node reduced to its shape alone, for asking "did the type change?" without
786
+ * a tightened bound answering yes.
787
+ *
788
+ * This is now the only comparison the diff makes. Constraints used to be kept
789
+ * in some comparisons so that a change to one registered as a difference, but
790
+ * every constraint is reported explicitly by constraintWarnings, and keeping
791
+ * them here only made a changed bound masquerade as a changed type. */
792
+ function stripToShape(node: unknown): unknown {
793
+ return stripKeys(node, SHAPE_KEYS)
794
+ }
795
+
796
+ /* Tightening a bound can invalidate a value an operator already stored, so it
797
+ * gates the same way a removed union member does. Loosening cannot, and is
798
+ * silent. Reported at release time on the schemas alone; rule 5 catches which
799
+ * devices are actually affected when an offer is considered. */
800
+ function constraintWarnings(prev: unknown, curr: unknown, path: string, out: string[]): void {
801
+ if (!isPlainObject(prev) || !isPlainObject(curr)) return
802
+ const report = (key: string, lowerIsLooser: boolean): void => {
803
+ const before = prev[key]
804
+ const after = curr[key]
805
+ if (after === undefined) return
806
+ const gate = (how: string): void => {
807
+ out.push(
808
+ `requires an operator: ${path} ${how} ${key} (stored overrides may no longer validate)`,
809
+ )
810
+ }
811
+ if (before === undefined) return gate('added')
812
+ if (typeof before !== 'number' || typeof after !== 'number') return
813
+ if (lowerIsLooser ? after > before : after < before) gate(lowerIsLooser ? 'raised' : 'lowered')
814
+ }
815
+ for (const key of ['min', 'minLength', 'minItems']) report(key, true)
816
+ for (const key of ['max', 'maxLength', 'maxItems']) report(key, false)
817
+ if (curr.integer === true && prev.integer !== true) {
818
+ out.push(
819
+ `requires an operator: ${path} now requires a whole number ` +
820
+ `(stored overrides may no longer validate)`,
821
+ )
822
+ }
823
+ if (curr.format !== undefined && prev.format !== curr.format) {
824
+ out.push(
825
+ `requires an operator: ${path} now requires format ${JSON.stringify(curr.format)} ` +
826
+ `(stored overrides may no longer validate)`,
827
+ )
828
+ }
829
+ if (prev.unit !== curr.unit) {
830
+ out.push(
831
+ `requires an operator: ${path} changed unit from ${JSON.stringify(prev.unit ?? null)} ` +
832
+ `to ${JSON.stringify(curr.unit ?? null)} (stored values are reinterpreted)`,
833
+ )
834
+ }
835
+
836
+ /* Descend where the outer walk does not. It recurses through object shapes
837
+ * only, so without this a tightened bound on an array element or a tuple
838
+ * position is silent: stripToShape removes constraints recursively, so the
839
+ * type comparison sees no change, and the checks above only read this node's
840
+ * own keys. A stranded override with no operator gate is exactly what the
841
+ * taxonomy exists to prevent. */
842
+ if (prev.kind === 'array' && curr.kind === 'array') {
843
+ constraintWarnings(prev.element, curr.element, `${path}[]`, out)
844
+ } else if (prev.kind === 'tuple' && curr.kind === 'tuple') {
845
+ const elements = curr.elements as unknown[]
846
+ const previous = prev.elements as unknown[]
847
+ for (let i = 0; i < Math.min(previous.length, elements.length); i++) {
848
+ constraintWarnings(previous[i], elements[i], `${path}[${i}]`, out)
849
+ }
850
+ } else if (prev.kind === 'optional' && curr.kind === 'optional') {
851
+ constraintWarnings(prev.inner, curr.inner, path, out)
852
+ } else if (prev.kind === 'object' && curr.kind === 'object') {
853
+ /* array(object({port: number({max: 65535})})) is an ordinary config shape,
854
+ * and without this the port's tightened bound is silent: the outer walk
855
+ * hands the array to stripToShape, which is equal, and the descent above
856
+ * reaches the element object and stops.
857
+ *
858
+ * No double-reporting with the outer walk: its object branch recurses per
859
+ * field and returns before reaching constraintWarnings, so an object is
860
+ * either walked or descended here, never both. */
861
+ const prevShape = prev.shape as Record<string, unknown>
862
+ const currShape = curr.shape as Record<string, unknown>
863
+ for (const key of Object.keys(currShape)) {
864
+ if (Object.hasOwn(prevShape, key)) {
865
+ constraintWarnings(prevShape[key], currShape[key], `${path}.${key}`, out)
866
+ }
867
+ }
868
+ } else if (prev.kind === 'taggedUnion' && curr.kind === 'taggedUnion') {
869
+ const prevBranches = prev.branches as Record<string, unknown>
870
+ const currBranches = curr.branches as Record<string, unknown>
871
+ for (const tag of Object.keys(currBranches)) {
872
+ if (Object.hasOwn(prevBranches, tag)) {
873
+ constraintWarnings(prevBranches[tag], currBranches[tag], `${path}.${tag}`, out)
874
+ }
875
+ }
876
+ }
877
+ }
878
+
363
879
  /** required / defaulted / optional, per the spec's scalar-leaf classes.
364
880
  * Containers have no class; objects report 'object' so the walk descends. */
365
881
  function leafClass(node: Schema): 'object' | 'optional' | 'defaulted' | 'required' | 'array' {
@@ -429,18 +945,43 @@ export function diffConfigSchemas(previous: Schema, next: Schema): string[] {
429
945
  // Member sets, not wholesale structure: adding a member (the common
430
946
  // safe widening) must not read as a type change. Only removals can
431
947
  // invalidate a stored override.
432
- const removed = prevInner.members.filter(
433
- (prevMember) =>
434
- !currInner.members.some((currMember) =>
435
- structuralEquals(stripAnnotations(prevMember), stripAnnotations(currMember)),
436
- ),
437
- )
438
- if (removed.length > 0) {
948
+ /* Counted by shape group, not tested for membership. Comparing on shape
949
+ * is what stops a merely loosened bound reading as a removal, but it also
950
+ * makes two differently bounded members of the same kind indistinguishable
951
+ * here: asking "does a member of this shape still exist?" answers yes when
952
+ * one of the two has gone. Dropping one of
953
+ * union([number({max: 10}), number({min: 100})]) would then be silent,
954
+ * stranding any override only the removed range accepted.
955
+ *
956
+ * A group whose count fell has lost a member. Counting keeps the loosened
957
+ * bound safe, since that leaves the count unchanged. */
958
+ const countOfShape = (members: readonly unknown[], shape: unknown) =>
959
+ members.filter((member) => structuralEquals(stripToShape(member), shape)).length
960
+
961
+ let removed = 0
962
+ const groups: unknown[] = []
963
+ for (const prevMember of prevInner.members) {
964
+ const shape = stripToShape(prevMember)
965
+ if (groups.some((seen) => structuralEquals(seen, shape))) continue
966
+ groups.push(shape)
967
+ const before = countOfShape(prevInner.members, shape)
968
+ const after = countOfShape(currInner.members, shape)
969
+ if (after < before) removed += before - after
970
+ }
971
+ if (removed > 0) {
439
972
  warnings.push(
440
- `requires an operator: ${path} removed ${removed.length} union member(s) ` +
973
+ `requires an operator: ${path} removed ${removed} union member(s) ` +
441
974
  `(stored overrides using them no longer validate)`,
442
975
  )
443
976
  }
977
+ // Constraint changes within members, index-wise. Members are ordered and
978
+ // an edit that also reorders them is not something this can attribute, so
979
+ // only compare when the lists still line up.
980
+ if (prevInner.members.length === currInner.members.length) {
981
+ for (let i = 0; i < prevInner.members.length; i++) {
982
+ constraintWarnings(prevInner.members[i], currInner.members[i], `${path}|${i}`, warnings)
983
+ }
984
+ }
444
985
  } else if (
445
986
  prevInner.kind === 'taggedUnion' &&
446
987
  currInner.kind === 'taggedUnion' &&
@@ -456,20 +997,25 @@ export function diffConfigSchemas(previous: Schema, next: Schema): string[] {
456
997
  `requires an operator: ${path} removed branch ${JSON.stringify(tag)} ` +
457
998
  `(stored overrides using it no longer validate)`,
458
999
  )
459
- } else if (!structuralEquals(stripAnnotations(prevBranch), stripAnnotations(currBranch))) {
1000
+ } else if (!structuralEquals(stripToShape(prevBranch), stripToShape(currBranch))) {
1001
+ // Shape, not constraints: a branch whose bound merely changed has not
1002
+ // changed type, and the descent in constraintWarnings reports it on
1003
+ // its own terms rather than as a reshaped branch.
460
1004
  warnings.push(
461
1005
  `requires an operator: ${path}.${tag} changed type ` +
462
1006
  `(stored overrides may no longer validate)`,
463
1007
  )
464
1008
  }
465
1009
  }
466
- } else if (!structuralEquals(stripAnnotations(prevInner), stripAnnotations(currInner))) {
1010
+ } else if (!structuralEquals(stripToShape(prevInner), stripToShape(currInner))) {
467
1011
  warnings.push(
468
1012
  `requires an operator: ${path} changed type (stored overrides may no longer validate)`,
469
1013
  )
470
1014
  return
471
1015
  }
472
1016
 
1017
+ constraintWarnings(prevInner, currInner, path, warnings)
1018
+
473
1019
  const prevClass = leafClass(prev)
474
1020
  const currClass = leafClass(curr)
475
1021
  if (currClass === 'required' && prevClass !== 'required') {