@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.
Files changed (68) hide show
  1. package/CMakeLists.txt +62 -1
  2. package/dist/index.d.ts +17 -0
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +11 -0
  5. package/dist/index.js.map +1 -1
  6. package/dist/runtime/result/native-result.node-shim.d.ts +3 -0
  7. package/dist/runtime/result/native-result.node-shim.d.ts.map +1 -0
  8. package/dist/runtime/result/native-result.node-shim.js +41 -0
  9. package/dist/runtime/result/native-result.node-shim.js.map +1 -0
  10. package/dist/runtime/result/types.d.ts +55 -0
  11. package/dist/runtime/result/types.d.ts.map +1 -0
  12. package/dist/runtime/result/types.js +2 -0
  13. package/dist/runtime/result/types.js.map +1 -0
  14. package/dist/runtime/schema/core.d.ts +115 -0
  15. package/dist/runtime/schema/core.d.ts.map +1 -0
  16. package/dist/runtime/schema/core.js +259 -0
  17. package/dist/runtime/schema/core.js.map +1 -0
  18. package/dist/runtime/schema/shared.d.ts +54 -0
  19. package/dist/runtime/schema/shared.d.ts.map +1 -0
  20. package/dist/runtime/schema/shared.js +489 -0
  21. package/dist/runtime/schema/shared.js.map +1 -0
  22. package/dist/types.d.ts +7 -0
  23. package/dist/types.d.ts.map +1 -1
  24. package/include/mikrojs/cbor_helpers.h +20 -0
  25. package/include/mikrojs/mem.h +11 -0
  26. package/include/mikrojs/mikrojs.h +2 -1
  27. package/include/mikrojs/ota_client.h +342 -0
  28. package/include/mikrojs/ota_config.h +100 -0
  29. package/include/mikrojs/ota_env.h +192 -0
  30. package/include/mikrojs/ota_js_hooks.h +71 -0
  31. package/include/mikrojs/ota_policy.h +131 -0
  32. package/include/mikrojs/ota_slots.h +47 -0
  33. package/include/mikrojs/sys_codec.h +61 -0
  34. package/package.json +7 -5
  35. package/prebuilds/darwin-arm64/mikrojs.napi.node +0 -0
  36. package/prebuilds/linux-arm64/mikrojs.napi.node +0 -0
  37. package/prebuilds/linux-x64/mikrojs.napi.node +0 -0
  38. package/runtime/internal.d.ts +22 -16
  39. package/runtime/kv/shared.ts +11 -5
  40. package/runtime/kv/types.ts +4 -4
  41. package/runtime/ota/client.ts +12 -51
  42. package/runtime/ota/config.ts +18 -0
  43. package/runtime/ota/ota.ts +28 -70
  44. package/runtime/ota/types.ts +220 -2
  45. package/runtime/schema/core.ts +539 -0
  46. package/runtime/schema/schema.ts +36 -314
  47. package/runtime/schema/shared.ts +494 -0
  48. package/runtime/schema/types.ts +84 -12
  49. package/scripts/bundle-runtime.js +33 -0
  50. package/scripts/gen-checkin-fixtures.js +323 -0
  51. package/src/builtins.cpp +7 -8
  52. package/src/fs.cpp +3 -0
  53. package/src/mem.cpp +38 -0
  54. package/src/mik_abort.cpp +8 -1
  55. package/src/mik_cbor.cpp +43 -5
  56. package/src/mik_inspect.cpp +128 -22
  57. package/src/mik_ota_client.cpp +1230 -0
  58. package/src/mik_ota_config.cpp +296 -0
  59. package/src/mik_ota_js_hooks.cpp +190 -0
  60. package/src/mik_ota_policy.cpp +419 -0
  61. package/src/mik_ota_slots.cpp +249 -0
  62. package/src/mik_repl.cpp +9 -3
  63. package/src/mik_result.cpp +3 -1
  64. package/src/mik_sys_codec.cpp +167 -0
  65. package/src/mikrojs.cpp +15 -0
  66. package/src/modules.cpp +32 -13
  67. package/runtime/ota/client-impl.ts +0 -590
  68. 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
+ }
@@ -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 declare function string(): StringSchema
126
- export declare function number(): NumberSchema
127
- export declare function boolean(): BooleanSchema
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>(value: T): LiteralSchema<T>
130
- export declare function array<S extends Schema>(element: S): ArraySchema<S>
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<Elements extends readonly Schema[]>(
135
- elements: [...Elements],
136
- ): TupleSchema<Elements>
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<Members extends readonly Schema[]>(
139
- members: [...Members],
140
- ): UnionSchema<Members>
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
- >(key: Key, branches: Branches): TaggedUnionSchema<Key, Branches>
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,