@mikrojs/native 0.18.0 → 0.18.1
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 +62 -1
- package/dist/index.d.ts +17 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -1
- package/dist/runtime/result/native-result.node-shim.d.ts +3 -0
- package/dist/runtime/result/native-result.node-shim.d.ts.map +1 -0
- package/dist/runtime/result/native-result.node-shim.js +41 -0
- package/dist/runtime/result/native-result.node-shim.js.map +1 -0
- package/dist/runtime/result/types.d.ts +55 -0
- package/dist/runtime/result/types.d.ts.map +1 -0
- package/dist/runtime/result/types.js +2 -0
- package/dist/runtime/result/types.js.map +1 -0
- package/dist/runtime/schema/core.d.ts +115 -0
- package/dist/runtime/schema/core.d.ts.map +1 -0
- package/dist/runtime/schema/core.js +259 -0
- package/dist/runtime/schema/core.js.map +1 -0
- package/dist/runtime/schema/shared.d.ts +54 -0
- package/dist/runtime/schema/shared.d.ts.map +1 -0
- package/dist/runtime/schema/shared.js +489 -0
- package/dist/runtime/schema/shared.js.map +1 -0
- package/dist/types.d.ts +7 -0
- package/dist/types.d.ts.map +1 -1
- package/include/mikrojs/cbor_helpers.h +20 -0
- package/include/mikrojs/mem.h +11 -0
- package/include/mikrojs/mikrojs.h +2 -1
- package/include/mikrojs/ota_client.h +342 -0
- package/include/mikrojs/ota_config.h +100 -0
- package/include/mikrojs/ota_env.h +192 -0
- package/include/mikrojs/ota_js_hooks.h +71 -0
- package/include/mikrojs/ota_policy.h +131 -0
- package/include/mikrojs/ota_slots.h +47 -0
- package/include/mikrojs/sys_codec.h +61 -0
- package/package.json +7 -5
- 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 +22 -16
- package/runtime/kv/shared.ts +11 -5
- package/runtime/kv/types.ts +4 -4
- package/runtime/ota/client.ts +12 -51
- package/runtime/ota/config.ts +18 -0
- package/runtime/ota/ota.ts +28 -70
- package/runtime/ota/types.ts +220 -2
- package/runtime/schema/core.ts +539 -0
- package/runtime/schema/schema.ts +36 -314
- package/runtime/schema/shared.ts +494 -0
- package/runtime/schema/types.ts +84 -12
- package/scripts/bundle-runtime.js +33 -0
- package/scripts/gen-checkin-fixtures.js +323 -0
- package/src/builtins.cpp +7 -8
- package/src/fs.cpp +3 -0
- package/src/mem.cpp +38 -0
- package/src/mik_abort.cpp +8 -1
- package/src/mik_cbor.cpp +43 -5
- package/src/mik_inspect.cpp +128 -22
- package/src/mik_ota_client.cpp +1230 -0
- package/src/mik_ota_config.cpp +296 -0
- package/src/mik_ota_js_hooks.cpp +190 -0
- package/src/mik_ota_policy.cpp +419 -0
- package/src/mik_ota_slots.cpp +249 -0
- package/src/mik_repl.cpp +9 -3
- package/src/mik_result.cpp +3 -1
- package/src/mik_sys_codec.cpp +167 -0
- package/src/mikrojs.cpp +15 -0
- package/src/modules.cpp +32 -13
- package/runtime/ota/client-impl.ts +0 -590
- package/runtime/ota/policy.ts +0 -299
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
/* Host-side helpers for config schemas, shared by the CLI and registries.
|
|
2
|
+
* Not part of the mikro/schema device bundle: the device never validates a
|
|
3
|
+
* schema AST (its manifest copy was written by the CLI) and never derives an
|
|
4
|
+
* overlay. Imports core.ts only, so hosts load it without resolving mikro/*
|
|
5
|
+
* builtins; results are plain {ok} shapes for the same reason. */
|
|
6
|
+
|
|
7
|
+
import {applyDefaults, type ObjectSchema, type Schema, SchemaError, validate} from './core.js'
|
|
8
|
+
|
|
9
|
+
export type SchemaCheck<T> = {ok: true; value: T} | {ok: false; error: SchemaError}
|
|
10
|
+
|
|
11
|
+
const MAX_DEPTH = 8
|
|
12
|
+
|
|
13
|
+
const KINDS = new Set([
|
|
14
|
+
'string',
|
|
15
|
+
'number',
|
|
16
|
+
'boolean',
|
|
17
|
+
'unknown',
|
|
18
|
+
'literal',
|
|
19
|
+
'array',
|
|
20
|
+
'object',
|
|
21
|
+
'optional',
|
|
22
|
+
'tuple',
|
|
23
|
+
'union',
|
|
24
|
+
'taggedUnion',
|
|
25
|
+
])
|
|
26
|
+
|
|
27
|
+
function fail(message: string, path: string) {
|
|
28
|
+
return {ok: false as const, error: SchemaError.ValidationFailed(message, path)}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/* JSON.parse creates `__proto__` as an own key, and downstream walks assign
|
|
32
|
+
* overlay values through `out[key] = …` — with these keys that assignment
|
|
33
|
+
* writes the prototype, not a property. The AST is untrusted, so refuse them
|
|
34
|
+
* outright. */
|
|
35
|
+
const UNSAFE_KEYS = new Set(['__proto__', 'constructor', 'prototype'])
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Validates an untrusted serialized schema AST as a config schema: well-formed
|
|
39
|
+
* nodes only, an object at the root, no `unknown()`, no `optional()` around an
|
|
40
|
+
* object or array (an overlay needs every absence to mean exactly one thing),
|
|
41
|
+
* defaults that match their own node, and nesting of at most 8 levels.
|
|
42
|
+
* The size cap is the caller's, since only the caller sees encoded bytes.
|
|
43
|
+
*/
|
|
44
|
+
export function parseConfigSchema(value: unknown): SchemaCheck<Schema> {
|
|
45
|
+
if (!isPlainObject(value) || (value as {kind?: unknown}).kind !== 'object') {
|
|
46
|
+
return fail('config schema root must be an object()', '')
|
|
47
|
+
}
|
|
48
|
+
const result = walk(value, '', 1)
|
|
49
|
+
if (result !== null) return result
|
|
50
|
+
return {ok: true, value: value as unknown as Schema}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
54
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/* A default below a wholesale unit never applies: applyDefaults replaces the
|
|
58
|
+
* unit whole, so only the unit's own whole-value default fills anything. The
|
|
59
|
+
* constructors reject it where it is written (core.ts, rejectInnerDefaults);
|
|
60
|
+
* a schema that arrived as JSON ran none of them, so the same rule is enforced
|
|
61
|
+
* here. The whole subtree is walked, not just the plain-object path, because
|
|
62
|
+
* nothing checked a nested unit's contents on the way in. Structure is already
|
|
63
|
+
* validated by `walk`, so a non-node here is left for it to report. */
|
|
64
|
+
function rejectInnerDefaults(
|
|
65
|
+
value: unknown,
|
|
66
|
+
path: string,
|
|
67
|
+
unit: string,
|
|
68
|
+
self: string,
|
|
69
|
+
): ReturnType<typeof fail> | null {
|
|
70
|
+
if (!isPlainObject(value)) return null
|
|
71
|
+
if (value.default !== undefined) {
|
|
72
|
+
return fail(
|
|
73
|
+
`a default under ${unit} never applies; give ${self} itself a whole-value default instead`,
|
|
74
|
+
path,
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
const children: [unknown, string][] = []
|
|
78
|
+
for (const key of ['shape', 'branches'] as const) {
|
|
79
|
+
const map = value[key]
|
|
80
|
+
if (isPlainObject(map)) {
|
|
81
|
+
for (const name of Object.keys(map)) children.push([map[name], `${path}.${name}`])
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
for (const key of ['element', 'inner'] as const) {
|
|
85
|
+
if (value[key] !== undefined) children.push([value[key], path])
|
|
86
|
+
}
|
|
87
|
+
for (const key of ['elements', 'members'] as const) {
|
|
88
|
+
const items = value[key]
|
|
89
|
+
if (Array.isArray(items)) {
|
|
90
|
+
for (let i = 0; i < items.length; i++) children.push([items[i], `${path}[${i}]`])
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
for (const [child, childPath] of children) {
|
|
94
|
+
const result = rejectInnerDefaults(child, childPath, unit, self)
|
|
95
|
+
if (result !== null) return result
|
|
96
|
+
}
|
|
97
|
+
return null
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function walk(value: unknown, path: string, depth: number): ReturnType<typeof fail> | null {
|
|
101
|
+
if (depth > MAX_DEPTH) return fail(`nesting deeper than ${MAX_DEPTH} levels`, path)
|
|
102
|
+
if (!isPlainObject(value)) return fail('expected a schema node', path)
|
|
103
|
+
const node = value
|
|
104
|
+
const kind = node.kind
|
|
105
|
+
if (typeof kind !== 'string' || !KINDS.has(kind)) {
|
|
106
|
+
return fail(`unknown schema kind ${JSON.stringify(kind)}`, path)
|
|
107
|
+
}
|
|
108
|
+
if (kind === 'unknown') return fail('unknown() is not allowed in a config schema', path)
|
|
109
|
+
|
|
110
|
+
switch (kind) {
|
|
111
|
+
case 'literal': {
|
|
112
|
+
const t = typeof node.value
|
|
113
|
+
if (t !== 'string' && t !== 'number' && t !== 'boolean') {
|
|
114
|
+
return fail('literal value must be a primitive', path)
|
|
115
|
+
}
|
|
116
|
+
break
|
|
117
|
+
}
|
|
118
|
+
case 'array': {
|
|
119
|
+
const result = walk(node.element, `${path}.element`, depth + 1)
|
|
120
|
+
if (result !== null) return result
|
|
121
|
+
const inner = rejectInnerDefaults(node.element, `${path}.element`, 'an array', 'the array')
|
|
122
|
+
if (inner !== null) return inner
|
|
123
|
+
break
|
|
124
|
+
}
|
|
125
|
+
case 'object': {
|
|
126
|
+
if (!isPlainObject(node.shape)) return fail('object shape must be a map', path)
|
|
127
|
+
if (node.default !== undefined) return fail('object() cannot carry a default', path)
|
|
128
|
+
for (const key of Object.keys(node.shape)) {
|
|
129
|
+
if (UNSAFE_KEYS.has(key)) return fail(`unsafe field name ${JSON.stringify(key)}`, path)
|
|
130
|
+
const result = walk(node.shape[key], `${path}.${key}`, depth + 1)
|
|
131
|
+
if (result !== null) return result
|
|
132
|
+
}
|
|
133
|
+
break
|
|
134
|
+
}
|
|
135
|
+
case 'optional': {
|
|
136
|
+
const inner = node.inner
|
|
137
|
+
if (isPlainObject(inner) && (inner.kind === 'object' || inner.kind === 'array')) {
|
|
138
|
+
return fail(`optional() cannot wrap an ${inner.kind} in a config schema`, path)
|
|
139
|
+
}
|
|
140
|
+
if (node.default !== undefined) {
|
|
141
|
+
// The wrapper never carries one (the constructor forbids it); in an
|
|
142
|
+
// untrusted AST it would validate here and then be ignored by
|
|
143
|
+
// applyDefaults, a default that silently never applies.
|
|
144
|
+
return fail('optional() cannot carry a default', path)
|
|
145
|
+
}
|
|
146
|
+
if (isPlainObject(inner) && inner.default !== undefined) {
|
|
147
|
+
return fail('optional() cannot wrap a schema with a default', path)
|
|
148
|
+
}
|
|
149
|
+
const result = walk(inner, path, depth + 1)
|
|
150
|
+
if (result !== null) return result
|
|
151
|
+
break
|
|
152
|
+
}
|
|
153
|
+
case 'tuple':
|
|
154
|
+
case 'union': {
|
|
155
|
+
const items = kind === 'tuple' ? node.elements : node.members
|
|
156
|
+
if (!Array.isArray(items)) return fail(`${kind} items must be an array`, path)
|
|
157
|
+
if (kind === 'union' && items.length === 0) {
|
|
158
|
+
// Unsatisfiable: no value matches an empty union, so it would only
|
|
159
|
+
// fail later, at serve, one device at a time.
|
|
160
|
+
return fail('union needs at least one member', path)
|
|
161
|
+
}
|
|
162
|
+
for (let i = 0; i < items.length; i++) {
|
|
163
|
+
const result = walk(items[i], `${path}[${i}]`, depth + 1)
|
|
164
|
+
if (result !== null) return result
|
|
165
|
+
const unit = kind === 'tuple' ? 'a tuple' : 'a union'
|
|
166
|
+
const inner = rejectInnerDefaults(items[i], `${path}[${i}]`, unit, `the ${kind}`)
|
|
167
|
+
if (inner !== null) return inner
|
|
168
|
+
}
|
|
169
|
+
break
|
|
170
|
+
}
|
|
171
|
+
case 'taggedUnion': {
|
|
172
|
+
if (typeof node.key !== 'string') return fail('taggedUnion key must be a string', path)
|
|
173
|
+
if (!isPlainObject(node.branches)) return fail('taggedUnion branches must be a map', path)
|
|
174
|
+
if (Object.keys(node.branches).length === 0) {
|
|
175
|
+
// Unsatisfiable, like an empty union: it would only fail at serve.
|
|
176
|
+
return fail('taggedUnion needs at least one branch', path)
|
|
177
|
+
}
|
|
178
|
+
for (const tag of Object.keys(node.branches)) {
|
|
179
|
+
if (UNSAFE_KEYS.has(tag)) return fail(`unsafe branch tag ${JSON.stringify(tag)}`, path)
|
|
180
|
+
const branch = node.branches[tag]
|
|
181
|
+
if (!isPlainObject(branch) || branch.kind !== 'object') {
|
|
182
|
+
return fail('taggedUnion branch must be an object()', `${path}.${tag}`)
|
|
183
|
+
}
|
|
184
|
+
const result = walk(branch, `${path}.${tag}`, depth + 1)
|
|
185
|
+
if (result !== null) return result
|
|
186
|
+
const inner = rejectInnerDefaults(branch, `${path}.${tag}`, 'a taggedUnion', 'the union')
|
|
187
|
+
if (inner !== null) return inner
|
|
188
|
+
}
|
|
189
|
+
break
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (node.default !== undefined) {
|
|
194
|
+
const check = validate(node as unknown as Schema, node.default, '')
|
|
195
|
+
if (check !== null) {
|
|
196
|
+
return fail(`default does not match the schema: ${check.error.message}`, path)
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return null
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Derives the overlay to store or serve from operator-supplied values: drops
|
|
204
|
+
* keys the schema does not know, strips values structurally equal to the
|
|
205
|
+
* schema default, and prunes empty objects and arrays. Returns undefined when
|
|
206
|
+
* nothing deviates from the defaults. Wholesale nodes (arrays, tuples, unions)
|
|
207
|
+
* are compared and kept as units; nothing inside them is stripped.
|
|
208
|
+
*/
|
|
209
|
+
export function deriveOverlay(schema: Schema, values: unknown): unknown {
|
|
210
|
+
switch (schema.kind) {
|
|
211
|
+
case 'object': {
|
|
212
|
+
if (values === undefined) return undefined
|
|
213
|
+
// A defined value of the wrong kind is kept, not dropped: dropping it
|
|
214
|
+
// would derive a clean overlay from garbage, so a typo'd save would
|
|
215
|
+
// succeed while storing nothing and rule 5 would never gate on it.
|
|
216
|
+
// Kept, it fails the merge-validate that every caller runs next.
|
|
217
|
+
if (!isPlainObject(values)) return values
|
|
218
|
+
const out: Record<string, unknown> = {}
|
|
219
|
+
for (const key of Object.keys(schema.shape)) {
|
|
220
|
+
const field = schema.shape[key]!
|
|
221
|
+
const value = values[key]
|
|
222
|
+
if (value === undefined) continue
|
|
223
|
+
const derived =
|
|
224
|
+
field.kind === 'optional'
|
|
225
|
+
? deriveOverlay(field.inner, value)
|
|
226
|
+
: deriveOverlay(field, value)
|
|
227
|
+
if (derived !== undefined) out[key] = derived
|
|
228
|
+
}
|
|
229
|
+
return Object.keys(out).length > 0 ? out : undefined
|
|
230
|
+
}
|
|
231
|
+
case 'array': {
|
|
232
|
+
if (values === undefined) return undefined
|
|
233
|
+
// Wrong kind kept for the same reason as objects above; only a real
|
|
234
|
+
// empty array prunes (emptiness is never a deliberate overlay state).
|
|
235
|
+
if (!Array.isArray(values)) return values
|
|
236
|
+
if (values.length === 0) return undefined
|
|
237
|
+
if (schema.default !== undefined && structuralEquals(values, schema.default)) {
|
|
238
|
+
return undefined
|
|
239
|
+
}
|
|
240
|
+
return stripDangerousKeys(values)
|
|
241
|
+
}
|
|
242
|
+
default: {
|
|
243
|
+
const fallback = (schema as {default?: unknown}).default
|
|
244
|
+
if (fallback !== undefined && structuralEquals(values, fallback)) return undefined
|
|
245
|
+
return stripDangerousKeys(values)
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Wholesale units travel with their unknown keys intact, but the three
|
|
251
|
+
* prototype-writing names never survive into a stored or served value:
|
|
252
|
+
* downstream walks and consumers assign through `out[key]`. */
|
|
253
|
+
function stripDangerousKeys(value: unknown): unknown {
|
|
254
|
+
if (Array.isArray(value)) return value.map(stripDangerousKeys)
|
|
255
|
+
if (isPlainObject(value)) {
|
|
256
|
+
const out: Record<string, unknown> = {}
|
|
257
|
+
for (const key of Object.keys(value)) {
|
|
258
|
+
if (UNSAFE_KEYS.has(key)) continue
|
|
259
|
+
out[key] = stripDangerousKeys(value[key])
|
|
260
|
+
}
|
|
261
|
+
return out
|
|
262
|
+
}
|
|
263
|
+
return value
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export function structuralEquals(a: unknown, b: unknown): boolean {
|
|
267
|
+
if (a === b) return true
|
|
268
|
+
if (Array.isArray(a) && Array.isArray(b)) {
|
|
269
|
+
if (a.length !== b.length) return false
|
|
270
|
+
for (let i = 0; i < a.length; i++) {
|
|
271
|
+
if (!structuralEquals(a[i], b[i])) return false
|
|
272
|
+
}
|
|
273
|
+
return true
|
|
274
|
+
}
|
|
275
|
+
if (isPlainObject(a) && isPlainObject(b)) {
|
|
276
|
+
const keysA = Object.keys(a)
|
|
277
|
+
const keysB = Object.keys(b)
|
|
278
|
+
if (keysA.length !== keysB.length) return false
|
|
279
|
+
for (const key of keysA) {
|
|
280
|
+
if (!Object.hasOwn(b, key) || !structuralEquals(a[key], b[key])) return false
|
|
281
|
+
}
|
|
282
|
+
return true
|
|
283
|
+
}
|
|
284
|
+
return false
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* The effective config for an overlay: defaults filled in, then validated.
|
|
289
|
+
* What a registry runs before serving and what `ota.config()` runs on read.
|
|
290
|
+
*/
|
|
291
|
+
export function parseEffective(schema: Schema, overlay: unknown): SchemaCheck<unknown> {
|
|
292
|
+
const effective = applyDefaults(schema, overlay)
|
|
293
|
+
const result = validate(schema, effective, '')
|
|
294
|
+
return result !== null ? result : {ok: true, value: effective}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* The partial defaults a schema materializes with no overrides: every field a
|
|
299
|
+
* default covers, and nothing else. Unlike parseEffective it never fails on a
|
|
300
|
+
* required defaultless field, it omits it. This is what pack bakes into the
|
|
301
|
+
* manifest and what a device reads when it holds no served document.
|
|
302
|
+
*
|
|
303
|
+
* Plain objects compose, so a nested one is included only when defaults fill
|
|
304
|
+
* it completely: a half-filled object would not validate, and the read type
|
|
305
|
+
* makes that field optional anyway. Wholesale units (array, tuple, union,
|
|
306
|
+
* taggedUnion) need a whole-value default on the node itself, matching
|
|
307
|
+
* applyDefaults, where a default inside an element or branch is a form hint.
|
|
308
|
+
* Optional fields rest on absence, so they are omitted too.
|
|
309
|
+
*/
|
|
310
|
+
export function materializeDefaults(schema: Schema): Record<string, unknown> {
|
|
311
|
+
return schema.kind === 'object' ? fillObject(schema).value : {}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** What defaults cover in an object shape. `complete` (every field covered or
|
|
315
|
+
* optional) is what makes a NESTED object safe to include. */
|
|
316
|
+
function fillObject(node: ObjectSchema): {value: Record<string, unknown>; complete: boolean} {
|
|
317
|
+
const out: Record<string, unknown> = {}
|
|
318
|
+
let complete = true
|
|
319
|
+
for (const key of Object.keys(node.shape)) {
|
|
320
|
+
const field = node.shape[key]!
|
|
321
|
+
if (field.kind === 'optional') continue
|
|
322
|
+
const filled = fillField(field)
|
|
323
|
+
if (filled === undefined) complete = false
|
|
324
|
+
else out[key] = filled.value
|
|
325
|
+
}
|
|
326
|
+
return {value: out, complete}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** The value defaults give one field, or undefined when nothing covers it. */
|
|
330
|
+
function fillField(node: Schema): {value: unknown} | undefined {
|
|
331
|
+
if (node.kind === 'object') {
|
|
332
|
+
const filled = fillObject(node)
|
|
333
|
+
return filled.complete ? {value: filled.value} : undefined
|
|
334
|
+
}
|
|
335
|
+
const fallback = (node as {default?: unknown}).default
|
|
336
|
+
return fallback === undefined ? undefined : {value: fallback}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** A node with its annotations removed, recursively, so two schemas can be
|
|
340
|
+
* compared on structure alone. */
|
|
341
|
+
function stripAnnotations(node: unknown): unknown {
|
|
342
|
+
if (!isPlainObject(node)) return node
|
|
343
|
+
const {default: _default, ...rest} = node
|
|
344
|
+
const out: Record<string, unknown> = {}
|
|
345
|
+
for (const key of Object.keys(rest)) {
|
|
346
|
+
const value = rest[key]
|
|
347
|
+
if (key === 'shape' || key === 'branches') {
|
|
348
|
+
const map = value as Record<string, unknown>
|
|
349
|
+
const stripped: Record<string, unknown> = {}
|
|
350
|
+
for (const k of Object.keys(map)) stripped[k] = stripAnnotations(map[k])
|
|
351
|
+
out[key] = stripped
|
|
352
|
+
} else if (key === 'element' || key === 'inner') {
|
|
353
|
+
out[key] = stripAnnotations(value)
|
|
354
|
+
} else if (key === 'elements' || key === 'members') {
|
|
355
|
+
out[key] = (value as unknown[]).map(stripAnnotations)
|
|
356
|
+
} else {
|
|
357
|
+
out[key] = value
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
return out
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** required / defaulted / optional, per the spec's scalar-leaf classes.
|
|
364
|
+
* Containers have no class; objects report 'object' so the walk descends. */
|
|
365
|
+
function leafClass(node: Schema): 'object' | 'optional' | 'defaulted' | 'required' | 'array' {
|
|
366
|
+
if (node.kind === 'optional') return 'optional'
|
|
367
|
+
if (node.kind === 'object') return 'object'
|
|
368
|
+
if (node.kind === 'array') return 'array'
|
|
369
|
+
return (node as {default?: unknown}).default !== undefined ? 'defaulted' : 'required'
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** Every required scalar leaf reachable in `node`, for reporting an added
|
|
373
|
+
* subtree that will gate offers until an operator supplies values. */
|
|
374
|
+
function requiredLeaves(node: Schema, path: string, out: string[]): void {
|
|
375
|
+
if (node.kind === 'object') {
|
|
376
|
+
for (const key of Object.keys(node.shape)) {
|
|
377
|
+
requiredLeaves(node.shape[key]!, `${path}.${key}`, out)
|
|
378
|
+
}
|
|
379
|
+
return
|
|
380
|
+
}
|
|
381
|
+
if (leafClass(node) === 'required') out.push(path)
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Human-readable warnings for what changed between two releases' config
|
|
386
|
+
* schemas, per the spec's change taxonomy (registry-spec.md, "Schema changes
|
|
387
|
+
* between releases"). Safe changes (new defaulted or optional fields, added
|
|
388
|
+
* union members, loosened requirements) produce nothing. "requires an
|
|
389
|
+
* operator" lines gate offers under rule 5 until someone supplies or fixes a
|
|
390
|
+
* value; "note" lines are compatible but worth telling the operator about.
|
|
391
|
+
*/
|
|
392
|
+
export function diffConfigSchemas(previous: Schema, next: Schema): string[] {
|
|
393
|
+
const warnings: string[] = []
|
|
394
|
+
|
|
395
|
+
function walk(prev: Schema, curr: Schema, path: string): void {
|
|
396
|
+
const prevInner = prev.kind === 'optional' ? prev.inner : prev
|
|
397
|
+
const currInner = curr.kind === 'optional' ? curr.inner : curr
|
|
398
|
+
|
|
399
|
+
if (prevInner.kind === 'object' && currInner.kind === 'object') {
|
|
400
|
+
const prevShape = prevInner.shape
|
|
401
|
+
const currShape = currInner.shape
|
|
402
|
+
for (const key of Object.keys(currShape)) {
|
|
403
|
+
const fieldPath = `${path}.${key}`
|
|
404
|
+
if (Object.hasOwn(prevShape, key)) {
|
|
405
|
+
walk(prevShape[key]!, currShape[key]!, fieldPath)
|
|
406
|
+
} else {
|
|
407
|
+
const added: string[] = []
|
|
408
|
+
requiredLeaves(currShape[key]!, fieldPath, added)
|
|
409
|
+
for (const leaf of added) {
|
|
410
|
+
warnings.push(
|
|
411
|
+
`requires an operator: new required field ${leaf} (devices are not offered ` +
|
|
412
|
+
`this release until a value is set)`,
|
|
413
|
+
)
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
for (const key of Object.keys(prevShape)) {
|
|
418
|
+
if (!Object.hasOwn(currShape, key)) {
|
|
419
|
+
warnings.push(
|
|
420
|
+
`note: removed field ${path}.${key} (stored overrides for it stay, but are no ` +
|
|
421
|
+
`longer served)`,
|
|
422
|
+
)
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
return
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
if (prevInner.kind === 'union' && currInner.kind === 'union') {
|
|
429
|
+
// Member sets, not wholesale structure: adding a member (the common
|
|
430
|
+
// safe widening) must not read as a type change. Only removals can
|
|
431
|
+
// 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) {
|
|
439
|
+
warnings.push(
|
|
440
|
+
`requires an operator: ${path} removed ${removed.length} union member(s) ` +
|
|
441
|
+
`(stored overrides using them no longer validate)`,
|
|
442
|
+
)
|
|
443
|
+
}
|
|
444
|
+
} else if (
|
|
445
|
+
prevInner.kind === 'taggedUnion' &&
|
|
446
|
+
currInner.kind === 'taggedUnion' &&
|
|
447
|
+
prevInner.key === currInner.key
|
|
448
|
+
) {
|
|
449
|
+
// Same idea per branch: added branches are safe, removed or reshaped
|
|
450
|
+
// ones can strand a stored override.
|
|
451
|
+
for (const tag of Object.keys(prevInner.branches)) {
|
|
452
|
+
const prevBranch = prevInner.branches[tag]!
|
|
453
|
+
const currBranch = currInner.branches[tag]
|
|
454
|
+
if (currBranch === undefined || !Object.hasOwn(currInner.branches, tag)) {
|
|
455
|
+
warnings.push(
|
|
456
|
+
`requires an operator: ${path} removed branch ${JSON.stringify(tag)} ` +
|
|
457
|
+
`(stored overrides using it no longer validate)`,
|
|
458
|
+
)
|
|
459
|
+
} else if (!structuralEquals(stripAnnotations(prevBranch), stripAnnotations(currBranch))) {
|
|
460
|
+
warnings.push(
|
|
461
|
+
`requires an operator: ${path}.${tag} changed type ` +
|
|
462
|
+
`(stored overrides may no longer validate)`,
|
|
463
|
+
)
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
} else if (!structuralEquals(stripAnnotations(prevInner), stripAnnotations(currInner))) {
|
|
467
|
+
warnings.push(
|
|
468
|
+
`requires an operator: ${path} changed type (stored overrides may no longer validate)`,
|
|
469
|
+
)
|
|
470
|
+
return
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
const prevClass = leafClass(prev)
|
|
474
|
+
const currClass = leafClass(curr)
|
|
475
|
+
if (currClass === 'required' && prevClass !== 'required') {
|
|
476
|
+
warnings.push(
|
|
477
|
+
`requires an operator: ${path} is now required (devices without a value are not ` +
|
|
478
|
+
`offered this release)`,
|
|
479
|
+
)
|
|
480
|
+
}
|
|
481
|
+
const prevDefault = (prevInner as {default?: unknown}).default
|
|
482
|
+
const currDefault = (currInner as {default?: unknown}).default
|
|
483
|
+
// A removed default is the required-transition above, not also a default
|
|
484
|
+
// change.
|
|
485
|
+
if (currDefault !== undefined && !structuralEquals(prevDefault, currDefault)) {
|
|
486
|
+
warnings.push(
|
|
487
|
+
`note: default of ${path} changed (takes effect on every device without an override)`,
|
|
488
|
+
)
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
walk(previous, next, '')
|
|
493
|
+
return warnings
|
|
494
|
+
}
|
package/runtime/schema/types.ts
CHANGED
|
@@ -4,14 +4,17 @@ type Primitive = string | number | boolean
|
|
|
4
4
|
|
|
5
5
|
export interface StringSchema {
|
|
6
6
|
readonly kind: 'string'
|
|
7
|
+
readonly default?: string
|
|
7
8
|
}
|
|
8
9
|
|
|
9
10
|
export interface NumberSchema {
|
|
10
11
|
readonly kind: 'number'
|
|
12
|
+
readonly default?: number
|
|
11
13
|
}
|
|
12
14
|
|
|
13
15
|
export interface BooleanSchema {
|
|
14
16
|
readonly kind: 'boolean'
|
|
17
|
+
readonly default?: boolean
|
|
15
18
|
}
|
|
16
19
|
|
|
17
20
|
export interface UnknownSchema {
|
|
@@ -21,11 +24,13 @@ export interface UnknownSchema {
|
|
|
21
24
|
export interface LiteralSchema<T extends Primitive = Primitive> {
|
|
22
25
|
readonly kind: 'literal'
|
|
23
26
|
readonly value: T
|
|
27
|
+
readonly default?: T
|
|
24
28
|
}
|
|
25
29
|
|
|
26
30
|
export interface ArraySchema<S extends Schema = Schema> {
|
|
27
31
|
readonly kind: 'array'
|
|
28
32
|
readonly element: S
|
|
33
|
+
readonly default?: unknown
|
|
29
34
|
}
|
|
30
35
|
|
|
31
36
|
export interface ObjectSchema<Shape extends Record<string, Schema> = Record<string, Schema>> {
|
|
@@ -41,11 +46,13 @@ export interface OptionalSchema<S extends Schema = Schema> {
|
|
|
41
46
|
export interface TupleSchema<Elements extends readonly Schema[] = readonly Schema[]> {
|
|
42
47
|
readonly kind: 'tuple'
|
|
43
48
|
readonly elements: Elements
|
|
49
|
+
readonly default?: unknown
|
|
44
50
|
}
|
|
45
51
|
|
|
46
52
|
export interface UnionSchema<Members extends readonly Schema[] = readonly Schema[]> {
|
|
47
53
|
readonly kind: 'union'
|
|
48
54
|
readonly members: Members
|
|
55
|
+
readonly default?: unknown
|
|
49
56
|
}
|
|
50
57
|
|
|
51
58
|
export interface TaggedUnionSchema<
|
|
@@ -55,6 +62,7 @@ export interface TaggedUnionSchema<
|
|
|
55
62
|
readonly kind: 'taggedUnion'
|
|
56
63
|
readonly key: Key
|
|
57
64
|
readonly branches: Branches
|
|
65
|
+
readonly default?: unknown
|
|
58
66
|
}
|
|
59
67
|
|
|
60
68
|
export type Schema =
|
|
@@ -120,28 +128,92 @@ type InferTaggedUnion<Key extends string, Branches> = {
|
|
|
120
128
|
[Tag in keyof Branches & string]: {[K in Key]: Tag} & Infer<Branches[Tag]>
|
|
121
129
|
}[keyof Branches & string]
|
|
122
130
|
|
|
131
|
+
/* The read type: what applyDefaults alone can hand back. A field defaults
|
|
132
|
+
* cannot fill is optional here, while Infer keeps it required. */
|
|
133
|
+
export type InferRead<S> =
|
|
134
|
+
S extends ObjectSchema<infer Shape>
|
|
135
|
+
? ObjectSchema extends S
|
|
136
|
+
? object
|
|
137
|
+
: Simplify<InferReadObject<Shape>>
|
|
138
|
+
: Infer<S>
|
|
139
|
+
|
|
140
|
+
/* Kept in lockstep with core.ts (and materializeDefaults in shared.ts). */
|
|
141
|
+
type Filled<S> = S extends {default: unknown}
|
|
142
|
+
? true
|
|
143
|
+
: S extends OptionalSchema
|
|
144
|
+
? false
|
|
145
|
+
: S extends ObjectSchema<infer Shape>
|
|
146
|
+
? ObjectSchema extends S
|
|
147
|
+
? false
|
|
148
|
+
: AllFilled<Shape>
|
|
149
|
+
: false
|
|
150
|
+
|
|
151
|
+
type AllFilled<Shape> = false extends {
|
|
152
|
+
[K in keyof Shape]: Shape[K] extends OptionalSchema ? true : Filled<Shape[K]>
|
|
153
|
+
}[keyof Shape]
|
|
154
|
+
? false
|
|
155
|
+
: true
|
|
156
|
+
|
|
157
|
+
type InferReadObject<Shape> = {
|
|
158
|
+
[K in keyof Shape as Filled<Shape[K]> extends true ? K : never]: InferRead<Shape[K]>
|
|
159
|
+
} & {
|
|
160
|
+
[K in keyof Shape as Filled<Shape[K]> extends true ? never : K]?: InferRead<Shape[K]>
|
|
161
|
+
}
|
|
162
|
+
|
|
123
163
|
export type SchemaError = {name: 'ValidationFailed'; message: string; path: string}
|
|
124
164
|
|
|
125
|
-
export
|
|
126
|
-
|
|
127
|
-
|
|
165
|
+
export interface ScalarOptions<T> {
|
|
166
|
+
readonly default?: T
|
|
167
|
+
}
|
|
168
|
+
export interface DefaultOption<T> {
|
|
169
|
+
readonly default?: T
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/* Mirrors core.ts: the constructors record a `default` annotation in their
|
|
173
|
+
* return type, which is what lets InferRead see it. */
|
|
174
|
+
type Defaulted<S, D> = [D] extends [undefined] ? S : S & {readonly default: unknown}
|
|
175
|
+
|
|
176
|
+
export declare function string<D extends string | undefined = undefined>(
|
|
177
|
+
options?: ScalarOptions<D>,
|
|
178
|
+
): Defaulted<StringSchema, D>
|
|
179
|
+
export declare function number<D extends number | undefined = undefined>(
|
|
180
|
+
options?: ScalarOptions<D>,
|
|
181
|
+
): Defaulted<NumberSchema, D>
|
|
182
|
+
export declare function boolean<D extends boolean | undefined = undefined>(
|
|
183
|
+
options?: ScalarOptions<D>,
|
|
184
|
+
): Defaulted<BooleanSchema, D>
|
|
128
185
|
export declare function unknown(): UnknownSchema
|
|
129
|
-
export declare function literal<T extends Primitive
|
|
130
|
-
|
|
186
|
+
export declare function literal<T extends Primitive, D extends T | undefined = undefined>(
|
|
187
|
+
value: T,
|
|
188
|
+
options?: ScalarOptions<D>,
|
|
189
|
+
): Defaulted<LiteralSchema<T>, D>
|
|
190
|
+
export declare function array<
|
|
191
|
+
S extends Schema,
|
|
192
|
+
D extends NoInfer<Infer<S>>[] | undefined = undefined,
|
|
193
|
+
>(element: S, options?: DefaultOption<D>): Defaulted<ArraySchema<S>, D>
|
|
131
194
|
export declare function object<Shape extends Record<string, Schema>>(
|
|
132
195
|
shape: Shape,
|
|
196
|
+
options?: DefaultOption<never>,
|
|
133
197
|
): ObjectSchema<Shape>
|
|
134
|
-
export declare function tuple<
|
|
135
|
-
|
|
136
|
-
|
|
198
|
+
export declare function tuple<
|
|
199
|
+
Elements extends readonly Schema[],
|
|
200
|
+
D extends NoInfer<Infer<TupleSchema<Elements>>> | undefined = undefined,
|
|
201
|
+
>(elements: [...Elements], options?: DefaultOption<D>): Defaulted<TupleSchema<Elements>, D>
|
|
137
202
|
export declare function optional<S extends Schema>(inner: S): OptionalSchema<S>
|
|
138
|
-
export declare function union<
|
|
139
|
-
|
|
140
|
-
|
|
203
|
+
export declare function union<
|
|
204
|
+
Members extends readonly Schema[],
|
|
205
|
+
D extends NoInfer<Infer<UnionSchema<Members>>> | undefined = undefined,
|
|
206
|
+
>(members: [...Members], options?: DefaultOption<D>): Defaulted<UnionSchema<Members>, D>
|
|
141
207
|
export declare function taggedUnion<
|
|
142
208
|
Key extends string,
|
|
143
209
|
Branches extends Record<string, ObjectSchema>,
|
|
144
|
-
|
|
210
|
+
D extends NoInfer<Infer<TaggedUnionSchema<Key, Branches>>> | undefined = undefined,
|
|
211
|
+
>(
|
|
212
|
+
key: Key,
|
|
213
|
+
branches: Branches,
|
|
214
|
+
options?: DefaultOption<D>,
|
|
215
|
+
): Defaulted<TaggedUnionSchema<Key, Branches>, D>
|
|
216
|
+
export declare function applyDefaults(schema: Schema, value: unknown): unknown
|
|
145
217
|
export declare function parse<S extends Schema>(
|
|
146
218
|
schema: S,
|
|
147
219
|
value: unknown,
|