@mikrojs/native 0.18.3-next.20260829153835 → 0.19.0

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 CHANGED
@@ -43,7 +43,10 @@ set(MIKROJS_CORE_SOURCES
43
43
  src/mik_text_encoding.cpp
44
44
  src/mik_color.cpp
45
45
  src/mik_abort.cpp
46
+ src/mik_http_client.cpp
47
+ src/mik_wifi_client.cpp
46
48
  src/mik_cbor.cpp
49
+ src/mik_schema.cpp
47
50
  src/mik_result.cpp
48
51
  src/mik_console.cpp
49
52
  src/mik_sys.cpp
@@ -88,7 +91,7 @@ endif()
88
91
  include(cmake/mikrojs_bytecode.cmake)
89
92
  mikrojs_generate_bytecode(
90
93
  RUNTIME_DIR "${CMAKE_CURRENT_SOURCE_DIR}/runtime"
91
- MODULES abort cbor env result schema fs http/helpers http/request i2c kv/nvs kv/rtc kv/shared module neopixel observable observable/lazy observable/operators ota ota/client ota/config pin pwm reader sleep spi sntp stdio stream sys test uart udp wifi
94
+ MODULES abort cbor env result schema fs i2c kv/nvs kv/rtc kv/shared module neopixel observable observable/lazy observable/operators ota ota/client ota/config pin pwm reader sleep spi sntp stdio stream sys test uart udp
92
95
  MODULE_PREFIX "mikro"
93
96
  SYMBOL_PREFIX "mikro"
94
97
  TARGET gen_bytecode
@@ -208,6 +211,8 @@ if(BUILD_TESTING)
208
211
  test/stream_test.cpp
209
212
  test/runtime_recycle_test.cpp
210
213
  test/udp_test.cpp
214
+ test/http_client_test.cpp
215
+ test/wifi_client_test.cpp
211
216
  test/observable_test.cpp
212
217
  test/unhandled_rejection_test.cpp
213
218
  test/ota_policy_test.cpp
@@ -215,6 +220,7 @@ if(BUILD_TESTING)
215
220
  test/ota_config_test.cpp
216
221
  test/ota_js_hooks_test.cpp
217
222
  test/ota_wire_fixtures_test.cpp
223
+ test/schema_conformance_test.cpp
218
224
  test/sys_codec_test.cpp
219
225
  )
220
226
 
@@ -234,9 +240,25 @@ if(BUILD_TESTING)
234
240
  )
235
241
  add_dependencies(mikrojs_tests gen_checkin_fixtures)
236
242
 
243
+ # ── Schema conformance fixtures ──────────────────────────────────
244
+ # mikro/schema has two implementations: @mikrojs/schema (host) and
245
+ # src/mik_schema.cpp (device). The generator records what core.ts does and
246
+ # test/schema_conformance_test.cpp replays it through the native module.
247
+ # Always regenerated so the fixtures cannot lag core.ts.
248
+ set(_SCHEMA_FIXTURE_DIR "${CMAKE_CURRENT_BINARY_DIR}/gen/schema-fixtures")
249
+ add_custom_target(gen_schema_fixtures
250
+ COMMAND node "${CMAKE_CURRENT_SOURCE_DIR}/scripts/gen-schema-fixtures.js"
251
+ "${_SCHEMA_FIXTURE_DIR}"
252
+ COMMENT "Generating schema conformance fixtures from @mikrojs/schema"
253
+ WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}"
254
+ VERBATIM
255
+ )
256
+ add_dependencies(mikrojs_tests gen_schema_fixtures)
257
+
237
258
  target_link_libraries(mikrojs_tests PRIVATE mikrojs)
238
259
  target_compile_definitions(mikrojs_tests PRIVATE
239
- "MIK_CHECKIN_FIXTURE_DIR=\"${_CHECKIN_FIXTURE_DIR}\"")
260
+ "MIK_CHECKIN_FIXTURE_DIR=\"${_CHECKIN_FIXTURE_DIR}\""
261
+ "MIK_SCHEMA_FIXTURE_DIR=\"${_SCHEMA_FIXTURE_DIR}\"")
240
262
  target_include_directories(mikrojs_tests PRIVATE
241
263
  "${CMAKE_CURRENT_SOURCE_DIR}/test"
242
264
  "${CMAKE_CURRENT_SOURCE_DIR}/deps/nanocbor/include"
@@ -115,6 +115,10 @@ struct MIKRuntime {
115
115
  * mik__result_ok_void. Safe to share because Result is immutable by
116
116
  * convention, and the singleton is frozen + non-extensible. */
117
117
  JSValue result_ok_void_singleton;
118
+ /* Shared BodyConsumedError constructor for the native mikro/http/helpers
119
+ * module. Created lazily on first http module init, freed in
120
+ * MIK_FreeRuntime. */
121
+ JSValue http_body_consumed_ctor;
118
122
  JSValue env_obj; /* Frozen object with env vars, set on import.meta.env */
119
123
  struct {
120
124
  JSValue on_data; /* JS callback for stdin data, JS_UNDEFINED when not listening */
@@ -255,9 +259,33 @@ std::string mik_inspect(JSContext* ctx, JSValue value, int depth = 2, bool color
255
259
  bool show_hidden = false);
256
260
  void mik__inspect_register(JSContext* ctx);
257
261
 
262
+ /* HTTP client modules mikro/http/helpers + mikro/http/request
263
+ * (mik_http_client.cpp). Loaded lazily via the C-module table in
264
+ * modules.cpp, not registered at runtime creation. */
265
+ JSModuleDef* mik__http_helpers_load(JSContext* ctx);
266
+ JSModuleDef* mik__http_request_load(JSContext* ctx);
267
+
268
+ /* Wifi client module mikro/wifi (mik_wifi_client.cpp), same lazy table. */
269
+ JSModuleDef* mik__wifi_client_load(JSContext* ctx);
270
+
271
+ /* Load `specifier` through the module loader on behalf of `importer` and
272
+ * return its namespace (modules.cpp). Honors virtual-module overrides. */
273
+ JSValue mik__load_module_ns(JSContext* ctx, const char* importer, const char* specifier,
274
+ bool require_evaluated);
275
+
276
+ /* Timer registry (timers.cpp) — direct scheduling for native callbacks that
277
+ * must not go through the JS setTimeout global. Schedule dups `func`. */
278
+ uint32_t MIK_Timer_Schedule(MIKTimers* timers, JSContext* ctx, JSValue func, int argc,
279
+ const JSValue* argv, int64_t timeout, bool is_interval, int64_t now);
280
+ bool MIK_Timer_UnSchedule(MIKTimers* timers, JSContext* ctx, uint32_t id);
281
+
258
282
  /* CBOR module (mik_cbor.cpp) */
259
283
  JSModuleDef* mik__cbor_init(JSContext* ctx);
260
284
 
285
+ /* Schema module (mik_schema.cpp). Registered lazily: the loader calls its
286
+ * init on first import of native:mikro/schema. */
287
+ void mik__schema_register(void);
288
+
261
289
  /* Result module (mik_result.cpp) */
262
290
  JSModuleDef* mik__result_init(JSContext* ctx);
263
291
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mikrojs/native",
3
- "version": "0.18.3-next.20260829153835",
3
+ "version": "0.19.0",
4
4
  "description": "Mikro.js C++ runtime library and Node.js native addon",
5
5
  "keywords": [
6
6
  "esp32",
@@ -70,8 +70,6 @@
70
70
  "./runtime/reader/types": "./runtime/reader/types.ts",
71
71
  "./runtime/result/native-result.node-shim": "./dist/runtime/result/native-result.node-shim.js",
72
72
  "./runtime/result/types": "./runtime/result/types.ts",
73
- "./runtime/schema/core": "./dist/runtime/schema/core.js",
74
- "./runtime/schema/shared": "./dist/runtime/schema/shared.js",
75
73
  "./runtime/schema/types": "./runtime/schema/types.ts",
76
74
  "./runtime/sleep/types": "./runtime/sleep/types.ts",
77
75
  "./runtime/sntp/types": "./runtime/sntp/types.ts",
@@ -88,14 +86,15 @@
88
86
  "cmake-js": "^8.0.0",
89
87
  "node-addon-api": "^8.7.0",
90
88
  "node-gyp-build": "^4.8.4",
91
- "@mikrojs/quickjs": "0.18.3-next.20260829153835+bf6b328"
89
+ "@mikrojs/quickjs": "0.19.0"
92
90
  },
93
91
  "devDependencies": {
94
92
  "@swc/core": "^1.15.30",
95
93
  "@types/node": "^24.12.2",
96
94
  "esbuild": "^0.28.0",
97
95
  "terser": "^5.46.2",
98
- "@mikrojs/registry": "0.18.3-next.20260829153835+bf6b328"
96
+ "@mikrojs/registry": "0.19.0",
97
+ "@mikrojs/schema": "0.19.0"
99
98
  },
100
99
  "engines": {
101
100
  "node": ">=24.0.0"
@@ -2,6 +2,11 @@
2
2
  // `native:mikro/http`. Apps driving HTTP through a non-lwIP transport (e.g. an
3
3
  // LTE modem over UART) implement the `Request` type directly and reuse
4
4
  // `prepareBody` + `makeResponse` for the boring parts.
5
+ //
6
+ // On-device, `mikro/http/helpers` is the native C module in
7
+ // src/mik_http_client.cpp (behavior pinned by test/http_client_test.cpp).
8
+ // This TS source stays as the type surface and as the Node-side double for
9
+ // the http/server vitest suite.
5
10
 
6
11
  import {err, ok} from 'mikro/result'
7
12
 
@@ -26,6 +26,34 @@ declare module 'native:mikro/cbor' {
26
26
  export function decode(data: Uint8Array): Result<unknown, CborError>
27
27
  }
28
28
 
29
+ declare module 'native:mikro/schema' {
30
+ import type * as S from '@mikrojs/native/runtime/schema/types'
31
+ /* typeof-aliased rather than re-exported: `export ... from` inside an
32
+ * ambient module declaration resolves to never, and writing the generic
33
+ * signatures out again here would be a third copy of the surface. */
34
+ export const string: typeof S.string
35
+ export const number: typeof S.number
36
+ export const boolean: typeof S.boolean
37
+ export const unknown: typeof S.unknown
38
+ export const literal: typeof S.literal
39
+ export const array: typeof S.array
40
+ export const object: typeof S.object
41
+ export const tuple: typeof S.tuple
42
+ export const optional: typeof S.optional
43
+ export const union: typeof S.union
44
+ export const enumOf: typeof S.enumOf
45
+ export const taggedUnion: typeof S.taggedUnion
46
+ export const applyDefaults: typeof S.applyDefaults
47
+ export const SchemaError: typeof S.SchemaError
48
+ /* The plain {ok, error} envelope core.ts's local err() builds, not a
49
+ * mikro/result Result: parse() in schema.ts wraps it. */
50
+ export function validate(
51
+ schema: S.Schema,
52
+ value: unknown,
53
+ path: string,
54
+ ): {ok: false; error: S.SchemaError} | null
55
+ }
56
+
29
57
  declare module 'native:mikro/result' {
30
58
  import type {ErrResult, OkResult} from './result/types.js'
31
59
  export function ok(): OkResult<void>
@@ -374,9 +374,9 @@ export interface Ota {
374
374
  * `applyConfig`, with `trialBoots`), and validate the offer fields (exactly
375
375
  * `parseOffer(raw)`, with `allowInsecure`). An empty or null response is
376
376
  * the registry's quiet round: nothing to deliver, and the confirm still
377
- * happens, which is the point of calling. A response that is anything else,
378
- * a string or a number, never decoded (a captive portal's HTML, a proxy's
379
- * error page), so it is not a completed round: nothing settles, and the
377
+ * happens, which is the point of calling. A response of any other shape
378
+ * never decoded: a captive portal handed back HTML, or a proxy sent an
379
+ * error page. That is not a completed round, so nothing settles and the
380
380
  * confirm does not run.
381
381
  *
382
382
  * Never call it on a failed request: a check-in that did not complete
@@ -1,7 +1,20 @@
1
+ /* mikro/schema. The constructors, the validator and applyDefaults are native
2
+ * (src/mik_schema.cpp); all this adds is the Result-returning parse(), which
3
+ * is the only part that needs mikro/result.
4
+ *
5
+ * @mikrojs/schema is the same DSL in TypeScript and is what the host
6
+ * runs (the CLI evaluating mikro.config.ts, the registry, vitest). The two are
7
+ * held together by scripts/gen-schema-fixtures.js and
8
+ * test/schema_conformance_test.cpp — change core.ts first, then mik_schema.cpp.
9
+ *
10
+ * validate() is deliberately not re-exported: parse() is the public entry, and
11
+ * that has been true since this module was bytecode. */
12
+
1
13
  import {err, ok} from 'mikro/result'
14
+ import {validate} from 'native:mikro/schema'
2
15
 
3
16
  import type {Result} from '../result/types.js'
4
- import {type Infer, type Schema, type SchemaError, validate} from './core.js'
17
+ import type {Infer, Schema, SchemaError} from './types.js'
5
18
 
6
19
  export type {
7
20
  ArrayOptions,
@@ -28,7 +41,7 @@ export type {
28
41
  UnionSchema,
29
42
  Unit,
30
43
  UnknownSchema,
31
- } from './core.js'
44
+ } from './types.js'
32
45
  export {
33
46
  applyDefaults,
34
47
  array,
@@ -44,7 +57,7 @@ export {
44
57
  tuple,
45
58
  union,
46
59
  unknown,
47
- } from './core.js'
60
+ } from 'native:mikro/schema'
48
61
 
49
62
  export function parse<S extends Schema>(schema: S, value: unknown): Result<Infer<S>, SchemaError> {
50
63
  const result = validate(schema, value, '')
@@ -281,7 +281,7 @@ export type InferRead<S> =
281
281
  : Simplify<InferReadObject<Shape>>
282
282
  : Infer<S>
283
283
 
284
- /* Kept in lockstep with core.ts (and materializeDefaults in shared.ts). */
284
+ /* Kept in lockstep with @mikrojs/schema (and its materializeDefaults). */
285
285
  type Filled<S> = S extends {default: unknown}
286
286
  ? true
287
287
  : S extends OptionalSchema
@@ -510,13 +510,17 @@ async function run(): Promise<void> {
510
510
  } catch (e) {
511
511
  const msg = formatThrown(e)
512
512
  emit({e: 7, s: suite.name, m: msg})
513
+ // Fail the suite's tests rather than skip them: a broken beforeAll is
514
+ // a broken run, and skipping made it indistinguishable from a
515
+ // deliberate gate. The e:7 above carries the real error; the per-test
516
+ // message just points at it.
513
517
  for (const t of suite.tests) {
514
518
  if (t.todo) {
515
519
  emit({e: 9, s: suite.name, t: t.name})
516
520
  todo++
517
521
  } else {
518
- emit({e: 4, s: suite.name, t: t.name})
519
- skipped++
522
+ emit({e: 3, s: suite.name, t: t.name, d: 0, m: 'beforeAll failed'})
523
+ failed++
520
524
  }
521
525
  }
522
526
  emit({e: 5, s: suite.name})
@@ -1,3 +1,6 @@
1
+ /* Type surface for mikro/wifi. On-device the module is the native
2
+ * implementation in src/mik_wifi_client.cpp (behavior pinned by
3
+ * test/wifi_client_test.cpp). */
1
4
  import type {Observable} from '../observable/types.js'
2
5
  import type {Result} from '../result/types.js'
3
6
 
@@ -0,0 +1,317 @@
1
+ /**
2
+ * Schema conformance fixtures: every case is run through the TypeScript
3
+ * implementation here and its outcome recorded, then replayed through the
4
+ * native module by test/schema_conformance_test.cpp.
5
+ *
6
+ * `mikro/schema` has two implementations of one contract: @mikrojs/schema
7
+ * (host: the CLI evaluating mikro.config.ts, the registry, vitest) and
8
+ * src/mik_schema.cpp (device). Two independent suites are not enough to keep
9
+ * them together — the check-in wire had exactly this shape and drifted while
10
+ * both suites stayed green (see test/ota_wire_fixtures_test.cpp). These
11
+ * fixtures make the C++ answer the same questions the TypeScript answered.
12
+ *
13
+ * The recorded outcome is whatever core.ts does, bugs included. That is the
14
+ * point: this file proves the two agree, and the vitest suite proves core.ts is
15
+ * right. Regenerated on every test build so it cannot lag.
16
+ *
17
+ * Usage: node gen-schema-fixtures.js <outdir>
18
+ */
19
+
20
+ import {mkdirSync, writeFileSync} from 'node:fs'
21
+ import {join} from 'node:path'
22
+
23
+ /* The source, not the package specifier: Node would resolve @mikrojs/schema
24
+ * through its `import` condition to dist and make this need a build first. */
25
+ const s = await import('../../schema/src/core.ts')
26
+
27
+ const outDir = process.argv[2]
28
+ if (!outDir) {
29
+ // eslint-disable-next-line no-console
30
+ console.error('Usage: gen-schema-fixtures.js <outdir>')
31
+ process.exit(1)
32
+ }
33
+
34
+ /* ── Cases ─────────────────────────────────────────────────────────── */
35
+
36
+ /* Constructor calls: the driver does S[call](...args) and compares the node it
37
+ * builds, or the TypeError message it throws. Args are plain data because a
38
+ * schema is plain data — that is what makes the same list drivable from C++. */
39
+ const CONSTRUCT_CASES = [
40
+ ['string()', 'string', []],
41
+ ['string with annotations', 'string', [{title: 'Name', description: 'Who', default: 'a'}]],
42
+ ['string with constraints', 'string', [{minLength: 1, maxLength: 8, format: 'email'}]],
43
+ ['string masked', 'string', [{mask: true}]],
44
+ ['number()', 'number', []],
45
+ ['number with bounds', 'number', [{min: 0, max: 10, integer: true, unit: 'ms'}]],
46
+ ['number with default', 'number', [{default: 5}]],
47
+ ['boolean with default', 'boolean', [{default: true}]],
48
+ ['unknown()', 'unknown', []],
49
+ ['literal string', 'literal', ['on']],
50
+ ['literal number with title', 'literal', [3, {title: 'Three'}]],
51
+ ['literal boolean', 'literal', [false]],
52
+ ['array of numbers', 'array', [{kind: 'number'}]],
53
+ ['array with item bounds', 'array', [{kind: 'string'}, {minItems: 1, maxItems: 4}]],
54
+ ['array with default', 'array', [{kind: 'number'}, {default: [1, 2]}]],
55
+ ['object empty', 'object', [{}]],
56
+ ['object with fields', 'object', [{a: {kind: 'number'}, b: {kind: 'string'}}]],
57
+ ['object with title', 'object', [{a: {kind: 'number'}}, {title: 'Group'}]],
58
+ ['optional wraps', 'optional', [{kind: 'number'}]],
59
+ ['tuple', 'tuple', [[{kind: 'number'}, {kind: 'string'}]]],
60
+ ['union', 'union', [[{kind: 'number'}, {kind: 'string'}]]],
61
+ [
62
+ 'enumOf',
63
+ 'enumOf',
64
+ [
65
+ [
66
+ {value: 1, title: 'One'},
67
+ {value: 2, description: 'Two'},
68
+ ],
69
+ ],
70
+ ],
71
+ ['enumOf with default', 'enumOf', [[{value: 'a'}, {value: 'b'}], {default: 'b'}]],
72
+ [
73
+ 'taggedUnion',
74
+ 'taggedUnion',
75
+ ['type', {a: {kind: 'object', shape: {}}, b: {kind: 'object', shape: {}}}],
76
+ ],
77
+
78
+ // Authoring rejections. These are TypeErrors thrown where the schema is
79
+ // written, and they are as much a part of the contract as validation is.
80
+ ['object rejects a default', 'object', [{}, {default: {}}]],
81
+ ['optional rejects a defaulted inner', 'optional', [{kind: 'number', default: 1}]],
82
+ ['array rejects an inner default', 'array', [{kind: 'number', default: 1}]],
83
+ [
84
+ 'array rejects a nested inner default',
85
+ 'array',
86
+ [{kind: 'object', shape: {a: {kind: 'number', default: 1}}}],
87
+ ],
88
+ ['tuple rejects an inner default', 'tuple', [[{kind: 'number', default: 1}]]],
89
+ ['union rejects an inner default', 'union', [[{kind: 'number', default: 1}]]],
90
+ [
91
+ 'taggedUnion rejects an inner default',
92
+ 'taggedUnion',
93
+ ['t', {a: {kind: 'object', shape: {x: {kind: 'number', default: 1}}}}],
94
+ ],
95
+ ['default must match the node', 'number', [{default: 'nope'}]],
96
+ ['array default must match', 'array', [{kind: 'number'}, {default: ['nope']}]],
97
+ ['default must satisfy its own bound', 'number', [{min: 10, default: 5}]],
98
+ ['default within its bound is fine', 'number', [{min: 1, max: 10, default: 5}]],
99
+ ['default must satisfy minLength', 'string', [{minLength: 3, default: 'ab'}]],
100
+ ]
101
+
102
+ /* Validation: schemas are built with the constructors for readability and
103
+ * serialized as data, which is what the device receives anyway. */
104
+ const V = {
105
+ str: s.string(),
106
+ num: s.number(),
107
+ bool: s.boolean(),
108
+ unk: s.unknown(),
109
+ litOn: s.literal('on'),
110
+ lit3: s.literal(3),
111
+ arrNum: s.array(s.number()),
112
+ obj: s.object({a: s.number(), b: s.string()}),
113
+ objOpt: s.object({a: s.number(), b: s.optional(s.string())}),
114
+ nested: s.object({outer: s.object({inner: s.array(s.number())})}),
115
+ tup: s.tuple([s.number(), s.string()]),
116
+ uni: s.union([s.number(), s.string()]),
117
+ tagged: s.taggedUnion('type', {
118
+ move: s.object({x: s.number()}),
119
+ stop: s.object({}),
120
+ }),
121
+ optNum: s.optional(s.number()),
122
+ bounded: s.number({min: 0, max: 10}),
123
+ whole: s.number({integer: true}),
124
+ fractional: s.number({min: 0.5, max: 2.5}),
125
+ sized: s.string({minLength: 2, maxLength: 4}),
126
+ items: s.array(s.number(), {minItems: 1, maxItems: 3}),
127
+ emailed: s.string({format: 'email'}),
128
+ boundedObj: s.object({pin: s.number({min: 0, max: 30, integer: true})}),
129
+ /* The rule the two-pass arrangement got wrong: a union accepts what ANY
130
+ * member accepts, so 150 has to pass on the second member even though it
131
+ * matches the first member's shape and fails its bound. */
132
+ splitRange: s.union([s.number({max: 10}), s.number({min: 100})]),
133
+ }
134
+
135
+ const VALIDATE_CASES = [
136
+ ['string accepts', V.str, 'hello'],
137
+ ['string rejects a number', V.str, 42],
138
+ ['string rejects null', V.str, null],
139
+ ['string rejects undefined', V.str, undefined],
140
+ ['number accepts', V.num, 1.5],
141
+ ['number accepts a negative', V.num, -7],
142
+ ['number rejects NaN', V.num, NaN],
143
+ ['number rejects a string', V.num, '1'],
144
+ ['boolean accepts', V.bool, false],
145
+ ['boolean rejects 0', V.bool, 0],
146
+ ['unknown accepts a string', V.unk, 'x'],
147
+ ['unknown accepts undefined', V.unk, undefined],
148
+ ['unknown accepts an object', V.unk, {a: 1}],
149
+ ['literal accepts its value', V.litOn, 'on'],
150
+ ['literal rejects another string', V.litOn, 'off'],
151
+ ['literal number accepts', V.lit3, 3],
152
+ ['literal number rejects', V.lit3, 4],
153
+ ['literal reports undefined', V.lit3, undefined],
154
+ ['array accepts', V.arrNum, [1, 2, 3]],
155
+ ['array accepts empty', V.arrNum, []],
156
+ ['array rejects a non-array', V.arrNum, 'no'],
157
+ ['array rejects an object', V.arrNum, {}],
158
+ ['array reports the bad index', V.arrNum, [1, 'two', 3]],
159
+ ['object accepts', V.obj, {a: 1, b: 'x'}],
160
+ ['object ignores extra keys', V.obj, {a: 1, b: 'x', extra: true}],
161
+ ['object reports a missing field', V.obj, {a: 1}],
162
+ ['object reports a bad field', V.obj, {a: 1, b: 2}],
163
+ ['object rejects an array', V.obj, [1, 2]],
164
+ ['object rejects null', V.obj, null],
165
+ ['optional field may be absent', V.objOpt, {a: 1}],
166
+ ['optional field may be present', V.objOpt, {a: 1, b: 'x'}],
167
+ ['optional field still typechecked', V.objOpt, {a: 1, b: 2}],
168
+ ['nested reports a deep path', V.nested, {outer: {inner: [1, 'no']}}],
169
+ ['nested accepts', V.nested, {outer: {inner: []}}],
170
+ ['tuple accepts', V.tup, [1, 'x']],
171
+ ['tuple rejects a short one', V.tup, [1]],
172
+ ['tuple rejects a long one', V.tup, [1, 'x', 3]],
173
+ ['tuple reports a bad position', V.tup, ['x', 'x']],
174
+ ['union accepts the first', V.uni, 1],
175
+ ['union accepts the second', V.uni, 'x'],
176
+ ['union rejects neither', V.uni, true],
177
+ ['taggedUnion accepts a branch', V.tagged, {type: 'move', x: 1}],
178
+ ['taggedUnion accepts the empty branch', V.tagged, {type: 'stop'}],
179
+ ['taggedUnion reports a missing tag', V.tagged, {x: 1}],
180
+ ['taggedUnion reports an unknown tag', V.tagged, {type: 'spin'}],
181
+ ['taggedUnion reports a non-primitive tag', V.tagged, {type: {}}],
182
+ ['taggedUnion validates the branch', V.tagged, {type: 'move', x: 'no'}],
183
+ ['taggedUnion rejects an array', V.tagged, []],
184
+ ['bare optional accepts undefined', V.optNum, undefined],
185
+ ['bare optional accepts a value', V.optNum, 1],
186
+ ['bare optional rejects a bad value', V.optNum, 'x'],
187
+
188
+ /* Bounds. These used to be host-only; parse() now enforces everything except
189
+ * format and unit wherever it runs. */
190
+ ['min accepts the boundary', V.bounded, 0],
191
+ ['max accepts the boundary', V.bounded, 10],
192
+ ['below min rejected', V.bounded, -1],
193
+ ['above max rejected', V.bounded, 11],
194
+ ['integer accepts a whole number', V.whole, 4],
195
+ ['integer rejects a fraction', V.whole, 4.5],
196
+ ['integer rejects Infinity', V.whole, Infinity],
197
+ ['fractional bounds accept', V.fractional, 1.25],
198
+ ['fractional bounds report the bound as written', V.fractional, 0.25],
199
+ ['minLength accepts the boundary', V.sized, 'ab'],
200
+ ['maxLength accepts the boundary', V.sized, 'abcd'],
201
+ ['shorter than minLength rejected', V.sized, 'a'],
202
+ ['longer than maxLength rejected', V.sized, 'abcde'],
203
+ ['length counts UTF-16 units', V.sized, '\u{1F600}'],
204
+ ['minItems accepts the boundary', V.items, [1]],
205
+ ['maxItems accepts the boundary', V.items, [1, 2, 3]],
206
+ ['fewer than minItems rejected', V.items, []],
207
+ ['more than maxItems rejected', V.items, [1, 2, 3, 4]],
208
+ ['length is checked before elements', V.items, [1, 2, 3, 'no']],
209
+ ['bounds report the field path', V.boundedObj, {pin: 200}],
210
+ ['format is not checked here', V.emailed, 'not-an-email'],
211
+ ['a union member failing its bound falls through', V.splitRange, 150],
212
+ ['a union with no member in range is rejected', V.splitRange, 50],
213
+
214
+ /* Prototype-chain hazards. core.ts uses Object.hasOwn at three sites so an
215
+ * inherited member cannot stand in for a field or a branch; JS_GetPropertyStr
216
+ * walks the prototype chain and would reopen exactly this. */
217
+ ['object does not read an inherited field', s.object({constructor: s.number()}), {}],
218
+ [
219
+ 'object accepts a real constructor field',
220
+ s.object({constructor: s.number()}),
221
+ {constructor: 1},
222
+ ],
223
+ ['taggedUnion does not dispatch on an inherited tag', V.tagged, {type: 'constructor'}],
224
+ ['taggedUnion does not dispatch on toString', V.tagged, {type: 'toString'}],
225
+ ['object does not read an inherited __proto__', s.object({a: s.number()}), {}],
226
+ ]
227
+
228
+ const APPLY_DEFAULTS_CASES = [
229
+ ['fills a scalar default', s.object({a: s.number({default: 5})}), undefined],
230
+ ['keeps a present value', s.object({a: s.number({default: 5})}), {a: 9}],
231
+ ['drops unknown keys', s.object({a: s.number({default: 5})}), {a: 1, z: 2}],
232
+ ['leaves an optional absent', s.object({a: s.optional(s.number())}), {}],
233
+ ['keeps a present optional', s.object({a: s.optional(s.number())}), {a: 1}],
234
+ ['recurses into nested objects', s.object({o: s.object({a: s.number({default: 2})})}), {}],
235
+ ['array default fills', s.object({a: s.array(s.number(), {default: [1]})}), {}],
236
+ ['array without a default is empty', s.object({a: s.array(s.number())}), {}],
237
+ ['a present non-object stays as-is', s.object({a: s.number()}), 42],
238
+ ['does not fill through an inherited key', s.object({constructor: s.number({default: 1})}), {}],
239
+ ['unknown passes through', s.unknown(), undefined],
240
+ ['scalar default at the root', s.number({default: 3}), undefined],
241
+ /* Hand-built AST: the constructors reject a default that breaks its own
242
+ * bound, but a schema that arrived as JSON ran none of them, and
243
+ * applyDefaults must still fill without validating. */
244
+ [
245
+ 'a default outside its own bound still fills',
246
+ {kind: 'object', shape: {a: {kind: 'number', min: 10, default: 5}}},
247
+ {},
248
+ ],
249
+ ]
250
+
251
+ /* ── Recording ─────────────────────────────────────────────────────── */
252
+
253
+ function recordConstruct([name, call, args]) {
254
+ try {
255
+ return {name, kind: 'construct', call, args, expect: s[call](...args)}
256
+ } catch (e) {
257
+ return {name, kind: 'construct', call, args, throws: e.message}
258
+ }
259
+ }
260
+
261
+ function recordValidate([name, schema, value]) {
262
+ const result = s.validate(schema, value, '')
263
+ return {
264
+ name,
265
+ kind: 'validate',
266
+ schema,
267
+ value,
268
+ expect: result === null ? null : {message: result.error.message, path: result.error.path},
269
+ }
270
+ }
271
+
272
+ function recordApplyDefaults([name, schema, value]) {
273
+ return {name, kind: 'applyDefaults', schema, value, expect: s.applyDefaults(schema, value)}
274
+ }
275
+
276
+ /* ── Emitting ──────────────────────────────────────────────────────── */
277
+
278
+ /* JS source, not JSON: the cases carry undefined and NaN, which JSON cannot
279
+ * represent and which are exactly the values the edge cases are about. */
280
+ function js(value) {
281
+ if (value === undefined) return 'undefined'
282
+ if (value === null) return 'null'
283
+ if (typeof value === 'number') {
284
+ if (Number.isNaN(value)) return 'NaN'
285
+ if (value === Infinity) return 'Infinity'
286
+ if (value === -Infinity) return '-Infinity'
287
+ return String(value)
288
+ }
289
+ if (typeof value === 'string' || typeof value === 'boolean') return JSON.stringify(value)
290
+ if (Array.isArray(value)) return `[${value.map(js).join(',')}]`
291
+ if (typeof value === 'object') {
292
+ const body = Object.keys(value)
293
+ .map((k) => `${JSON.stringify(k)}:${js(value[k])}`)
294
+ .join(',')
295
+ return `{${body}}`
296
+ }
297
+ throw new TypeError(`cannot serialize ${typeof value}`)
298
+ }
299
+
300
+ const cases = [
301
+ ...CONSTRUCT_CASES.map(recordConstruct),
302
+ ...VALIDATE_CASES.map(recordValidate),
303
+ ...APPLY_DEFAULTS_CASES.map(recordApplyDefaults),
304
+ ]
305
+
306
+ const body = cases.map((c) => ` ${js(c)},`).join('\n')
307
+ const source = `/* GENERATED by scripts/gen-schema-fixtures.js. Do not edit. */
308
+ const cases = [
309
+ ${body}
310
+ ]
311
+ `
312
+
313
+ mkdirSync(outDir, {recursive: true})
314
+ writeFileSync(join(outDir, 'schema-fixtures.js'), source)
315
+
316
+ // eslint-disable-next-line no-console
317
+ console.log(`schema fixtures: ${cases.length} cases -> ${join(outDir, 'schema-fixtures.js')}`)
@@ -337,7 +337,15 @@ int MIK_LoadConfig(const char* base_path, MIKConfig* config) {
337
337
  config->stack_size = (size_t)num_val;
338
338
  }
339
339
  if (mik__json_get_number(buf, "memReserved", &num_val)) {
340
- config->mem_reserved = (uint32_t)num_val;
340
+ /* The uint32 cast would wrap a negative value into a ~4 GB
341
+ * reserve; keep the default instead. */
342
+ if (num_val < 0) {
343
+ platform->log(MIK_LOG_WARN, TAG,
344
+ "Ignoring negative memReserved (%ld); using default",
345
+ (long)num_val);
346
+ } else {
347
+ config->mem_reserved = (uint32_t)num_val;
348
+ }
341
349
  }
342
350
  if (mik__json_get_number(buf, "fsReadMax", &num_val)) {
343
351
  config->fs_read_max = (uint32_t)num_val;