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