@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.
- package/CMakeLists.txt +1 -0
- package/dist/runtime/schema/core.d.ts +68 -20
- package/dist/runtime/schema/core.d.ts.map +1 -1
- package/dist/runtime/schema/core.js +51 -4
- package/dist/runtime/schema/core.js.map +1 -1
- package/dist/runtime/schema/shared.d.ts +26 -1
- package/dist/runtime/schema/shared.d.ts.map +1 -1
- package/dist/runtime/schema/shared.js +533 -17
- package/dist/runtime/schema/shared.js.map +1 -1
- package/include/mikrojs/mem.h +4 -0
- package/include/mikrojs/ota_client.h +3 -3
- package/include/mikrojs/platform.h +8 -0
- package/package.json +3 -3
- package/prebuilds/darwin-arm64/mikrojs.napi.node +0 -0
- package/prebuilds/linux-arm64/mikrojs.napi.node +0 -0
- package/prebuilds/linux-x64/mikrojs.napi.node +0 -0
- package/runtime/internal.d.ts +13 -2
- package/runtime/ota/client.ts +4 -0
- package/runtime/ota/ota.ts +4 -0
- package/runtime/ota/types.ts +75 -0
- package/runtime/schema/core.ts +325 -22
- package/runtime/schema/schema.ts +9 -0
- package/runtime/schema/shared.ts +568 -22
- package/runtime/schema/types.ts +192 -14
- package/runtime/sys/types.ts +11 -1
- package/src/mem.cpp +83 -37
- package/src/mik_ota_client.cpp +3 -3
- package/src/mik_sys.cpp +9 -1
package/runtime/schema/shared.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
195
|
-
|
|
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
|
-
|
|
294
|
-
|
|
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
|
-
|
|
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(
|
|
346
|
-
|
|
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] =
|
|
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] =
|
|
775
|
+
out[key] = stripKeys(value, keys)
|
|
354
776
|
} else if (key === 'elements' || key === 'members') {
|
|
355
|
-
out[key] = (value as unknown[]).map(
|
|
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
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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') {
|