@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
@@ -1,77 +1,35 @@
1
- import {readFile} from 'mikro/fs'
2
- import {sysGet, sysSet} from 'native:mikro/nvs_kv'
3
- import * as native from 'native:mikro/ota'
1
+ // `mikro/ota` the low-level update surface, for an app that talks to its own
2
+ // registry. The built-in client (`mikro/ota/client`) covers the ordinary case
3
+ // and does not go through here.
4
+ //
5
+ // Every member is the C policy (src/mik_ota_policy.cpp): the retry budget, the
6
+ // trial gates and the staging session all live there, so this module and the
7
+ // built-in client cannot disagree about the crash-loop latch they share.
8
+
9
+ import {config} from 'mikro/ota/config'
10
+ import {
11
+ applyOffer,
12
+ bearer,
13
+ confirm,
14
+ parseOffer,
15
+ reconcile,
16
+ registry,
17
+ revert,
18
+ running,
19
+ } from 'native:mikro/ota_client'
4
20
 
5
- import {createOta, type OtaStore} from './policy.js'
6
21
  import type {Ota} from './types.js'
7
22
 
8
- // Policy state, persisted to the mik.sys NVS namespace so the retry budget
9
- // survives a crash-loop and app-level nvsStorage.clear() can't wipe it.
10
- // NVS keys are capped at 15 chars.
11
- // A dropped write cannot be recovered from here, but it must not pass in
12
- // silence: `ota.tries` and `ota.inflight` are the crash-loop latch, so losing
13
- // either hands the retry budget back on every boot and the bound that stops a
14
- // panicking build from being retried forever is gone.
15
- function put(key: string, value: string | number): void {
16
- const r = sysSet(key, value)
17
- // eslint-disable-next-line no-console
18
- if (!r.ok) console.error(`ota: could not persist ${key}`, r.error)
19
- }
20
-
21
- const store: OtaStore = {
22
- getUrl: () => {
23
- const v = sysGet('ota.url')
24
- return typeof v === 'string' ? v : undefined
25
- },
26
- setUrl: (url) => put('ota.url', url),
27
- getAttempt: () => {
28
- const v = sysGet('ota.att')
29
- return typeof v === 'string' ? v : undefined
30
- },
31
- setAttempt: (checksum) => put('ota.att', checksum),
32
- getTries: () => {
33
- const v = sysGet('ota.tries')
34
- return typeof v === 'number' ? v : 0
35
- },
36
- setTries: (n) => put('ota.tries', n),
37
- getBad: () => {
38
- const v = sysGet('ota.bad')
39
- return typeof v === 'string' ? v : undefined
40
- },
41
- setBad: (checksum) => put('ota.bad', checksum),
42
- getInFlight: () => sysGet('ota.inflight') === 1,
43
- setInFlight: (value) => put('ota.inflight', value ? 1 : 0),
44
- }
45
-
46
- // Written as a pair to mik.sys by `mikro ota enroll`: the registry url and
47
- // the device update key that authenticates against it.
48
- function bearer(): string | undefined {
49
- const v = sysGet('ota.updateKey')
50
- return typeof v === 'string' ? v : undefined
51
- }
52
-
53
- function registry(): string | undefined {
54
- const v = sysGet('ota.registry')
55
- return typeof v === 'string' ? v : undefined
56
- }
57
-
58
- function readAppVersion(): string | undefined {
59
- const r = readFile('/app/package.json', 'utf-8')
60
- if (!r.ok) return undefined
61
- try {
62
- const pkg = JSON.parse(r.value) as {version?: unknown}
63
- return typeof pkg.version === 'string' ? pkg.version : undefined
64
- } catch {
65
- return undefined
66
- }
67
- }
68
-
69
- const ota: Ota = createOta({
70
- native,
71
- store,
72
- readAppVersion,
23
+ const ota: Ota = {
24
+ reconcile,
25
+ running,
26
+ parseOffer,
27
+ applyOffer,
28
+ confirm,
29
+ revert,
73
30
  bearer,
74
31
  registry,
75
- })
32
+ config,
33
+ }
76
34
 
77
35
  export {ota}
@@ -1,3 +1,6 @@
1
+ import type {RequestError} from 'mikro/http/helpers'
2
+
3
+ import type {CborError} from '../cbor/types.js'
1
4
  import type {Result} from '../result/types.js'
2
5
 
3
6
  /** An update offered by a registry: what to fetch and how to verify it, nothing
@@ -114,6 +117,69 @@ export type ApplyOutcome =
114
117
  * mismatch code, since it cannot tell a bad hash from bad bytes. */
115
118
  export type OtaError = OtaWriteError | OtaBeginError | OtaInstallError | OtaDownloadError
116
119
 
120
+ /** One stored config document: the complete effective config computed and
121
+ * validated by the registry (or by the CLI at cable-seed time), the opaque
122
+ * token that identifies it, and the release version it was computed for.
123
+ * The device stores and returns it without understanding it: validation is
124
+ * the writer's job, and the registry already ships the code. */
125
+ export interface StoredConfig {
126
+ /** Opaque registry-issued token echoed as `configRev` on check-ins. The
127
+ * registry serves its document whenever the echo differs from its own
128
+ * current rev, so a document it does not recognize is replaced. */
129
+ rev?: string
130
+ /** The release version this document was computed for. A document stamped
131
+ * for another version is ignored by `ota.config()`. */
132
+ version: string
133
+ /** The served document: the deviation overlay the read resolves over the
134
+ * build's manifest defaults. */
135
+ doc?: unknown
136
+ }
137
+
138
+ /** The three config slots, mirroring the build's install slots: `current` is
139
+ * what the running build reads, `next` is staged with an offered build,
140
+ * `prev` is the rollback baseline while a trial is unresolved. */
141
+ export type ConfigSlot = 'current' | 'next' | 'prev'
142
+
143
+ /** A running-release config delivery on trial. `left` is the boot budget
144
+ * (each boot that reads config burns one); `read` records that the app has
145
+ * read the document since delivery. A completed check-in adopts the trial
146
+ * only once that is true, or an app that reads config only at boot could
147
+ * have a never-executed document adopted under it. */
148
+ export interface ConfigTrial {
149
+ left: number
150
+ read: boolean
151
+ }
152
+
153
+ /** A config document rolled back after a failed trial; reported on check-ins
154
+ * while it stands. `rev` names the failed document, and the client keeps
155
+ * echoing it as `configRev`, which is what stops the registry re-serving
156
+ * the same document until an operator changes the config. */
157
+ export interface ConfigErrorReport {
158
+ rev: string
159
+ message: string
160
+ }
161
+
162
+ declare global {
163
+ /**
164
+ * Merge your app's config type into this interface (next to the schema
165
+ * definition) to type `ota.config()` app-wide, with no type parameter at
166
+ * the call sites:
167
+ *
168
+ * ```ts
169
+ * declare global {
170
+ * interface OtaConfig extends InferRead<typeof ConfigSchema> {}
171
+ * }
172
+ * ```
173
+ *
174
+ * A global rather than a module augmentation because `mikro/ota` re-exports
175
+ * its types, and module augmentation does not merge through re-exports. An
176
+ * explicit `ota.config<T>()` still works and wins over the registration.
177
+ */
178
+ interface OtaConfig {}
179
+ }
180
+
181
+ export type RegisteredConfig = keyof OtaConfig extends never ? unknown : OtaConfig
182
+
117
183
  export interface Ota {
118
184
  /** Report what happened to a previous update on this boot, and clear the report. */
119
185
  reconcile(): InstallOutcome
@@ -122,8 +188,10 @@ export interface Ota {
122
188
  /** Validate a registry value into an `Offer`, or `undefined` if unusable.
123
189
  * `allowInsecure` (dev only) accepts an http build url instead of https. */
124
190
  parseOffer(raw: unknown, opts?: {allowInsecure?: boolean}): Offer | undefined
125
- /** Run the full update policy: skip checks, compatibility, retry limit,
126
- * download via the `download` callback, and verification. */
191
+ /** Run the full update policy: skip checks and the retry limit, download
192
+ * via the `download` callback, and verification. Compatibility is not
193
+ * re-checked: the registry selected this build for the reported firmware,
194
+ * and a mismatched archive fails its checksum or fails to load. */
127
195
  applyOffer(
128
196
  offer: Offer,
129
197
  download: DownloadFn,
@@ -140,8 +208,158 @@ export interface Ota {
140
208
  /** The registry url the device was enrolled against, written next to the
141
209
  * update key at enrollment, or `undefined` on an un-enrolled device. */
142
210
  registry(): string | undefined
211
+ /**
212
+ * The app's effective config: the running build's manifest defaults with the
213
+ * document the registry computed for this release spread over them, top level
214
+ * only. Always an object for a build that went through the tooling, so no
215
+ * `?? fallback` at the call sites; fields the schema gives no default are the
216
+ * ones that can be absent, and the read type marks exactly those optional.
217
+ *
218
+ * Reads current state on every call: it changes exactly when a check-in
219
+ * completes, and every call hands back a fresh object, so mutating one never
220
+ * reaches the cached defaults. The one thing held between calls is the last
221
+ * document that read successfully, which is served while, and only while, the
222
+ * store cannot answer. A read fails under heap pressure, and flipping a
223
+ * running app onto the defaults for a beat would re-configure its hardware
224
+ * mid-handshake. A cleared document removes the key and reads back as an
225
+ * honest absence, so a clear is never mistaken for a failure.
226
+ *
227
+ * The device never merges deeper than one level and never validates: every
228
+ * writer of the document validated it against this release's schema before
229
+ * writing, and the type comes from the same source definition
230
+ * (`ota.config<Config>()` with the schema's `InferRead`).
231
+ *
232
+ * Throws when there is nothing to serve at all: a build carrying no readable
233
+ * manifest and no stored document (deploy it with `mikro deploy`, or run
234
+ * `mikro dev`), or a store that failed before any read succeeded this runtime
235
+ * on a build whose manifest could not be parsed either. Both are transient or
236
+ * fixable states, never a value the app has to branch on.
237
+ */
238
+ config<T = RegisteredConfig>(): T
143
239
  }
144
240
 
145
241
  /** The `mikro/ota` singleton. The runtime value is provided by the on-device
146
242
  * builtin (or the sim stub); this declaration carries its type for hosts. */
147
243
  export declare const ota: Ota
244
+
245
+ /* ── mikro/ota/client ──────────────────────────────────────────────────────
246
+ * The check-in client's surface. The implementation is C
247
+ * (src/mik_ota_client.cpp); these are the shapes it marshals across. */
248
+
249
+ export interface CheckOptions {
250
+ /** Budget for the check-in round trip. Default 10s. */
251
+ checkinTimeoutMs?: number
252
+ /** Budget for the build download. Separate from the check-in's because it is
253
+ * a total wallclock deadline that cancels the transfer mid-stream, and an
254
+ * image needs orders of magnitude more of it than a check-in body does.
255
+ * Default 5m. */
256
+ downloadTimeoutMs?: number
257
+ /** Require a completed check-in (via `ota.confirm()`, which the client fires
258
+ * itself) before an installed build is kept. Default true. */
259
+ requireConfirm?: boolean
260
+ /** Clean boots a trial may consume before an unconfirmed build reverts.
261
+ * A deep-sleep wake counts as a clean boot, so wake-cycle devices on flaky
262
+ * networks should raise this above the default 1. The same budget arms a
263
+ * delivered config document's trial. */
264
+ trialBoots?: number
265
+ }
266
+
267
+ /** Runs after its round settles: after the check and any download, and before
268
+ * an auto-restart, so the network brought up in `beforeCheck` can go down.
269
+ *
270
+ * Whatever it returns is ignored, so it can call something that reports a
271
+ * Result without having to unwrap or discard it. A promise is awaited before
272
+ * the round is considered over. */
273
+ export type Teardown = () => unknown
274
+
275
+ /** What a `beforeCheck` hands back:
276
+ *
277
+ * - a **function**: the round runs, and that function runs after it
278
+ * - **nothing**: the round runs, with no teardown
279
+ * - an **`err`**: the round is skipped and retried at the failure interval,
280
+ * and no teardown runs, since unwinding a partial setup is the hook's own job
281
+ * - an **`ok`**: as its value, a teardown function or nothing
282
+ *
283
+ * Throwing has the same effect as returning an `err`. The bare-function form
284
+ * is there because wrapping a teardown in `ok()` is ceremony on the path that
285
+ * always succeeds; the Result form is what lets a failing setup hand its own
286
+ * error straight back. */
287
+ export type BeforeCheckResult = Teardown | void | Result<Teardown | void, unknown>
288
+
289
+ export interface WatchOptions extends CheckOptions {
290
+ /** Steady interval between rounds, end-of-round to start-of-next. Default
291
+ * 30m, floored at 30s: each round's TLS session leaves heap and socket
292
+ * residue that needs time to drain on small-heap devices, and the value
293
+ * may arrive from remote config, and the floor is what bounds the damage a
294
+ * mistyped document can do. */
295
+ checkinIntervalMs?: number
296
+ /** Delay before the first round. Default 5s. */
297
+ initialDelayMs?: number
298
+ /** Interval after a failed round, capped at `checkinIntervalMs`. Default 1m. */
299
+ retryAfterFailureMs?: number
300
+ /** Spread every scheduled sleep by ±10%, so a fleet that lost power together
301
+ * does not check in phase-locked forever. Default true; pass false for
302
+ * exact intervals (a demo watching for the update to land, a single
303
+ * device where the spread only delays it). */
304
+ jitter?: boolean
305
+ /** Bring the network up for one round. State shared with the teardown stays
306
+ * in this one scope. See {@link BeforeCheckResult} for what to hand back. */
307
+ beforeCheck?(): BeforeCheckResult | Promise<BeforeCheckResult>
308
+ /** Called after a completed round changes the effective config the running
309
+ * build reads: delivered or cleared by that round, or applied by this boot's
310
+ * install or rollback. Receives the new effective config. A watch loop is
311
+ * otherwise silent, so without this an app has to poll `ota.config()` to
312
+ * notice.
313
+ *
314
+ * Not called for a config staged alongside an offered build: that one
315
+ * applies at its trial boot, so the running app cannot read it yet. */
316
+ onConfig?(config: RegisteredConfig): void
317
+ }
318
+
319
+ export interface Watcher {
320
+ /** Prevent future rounds and cancel the pending sleep. An in-flight round
321
+ * completes, but a build it stages no longer auto-restarts: it stays armed
322
+ * for the next natural reboot. */
323
+ stop(): void
324
+ /** Change the cadence without restarting the watcher, floored at 30s like
325
+ * {@link WatchOptions.checkinIntervalMs}. A wait already counting is re-timed
326
+ * from when it started, so a shorter interval brings the next round forward
327
+ * rather than waiting out the old one. The initial delay is left alone. */
328
+ setCheckinInterval(intervalMs: number): void
329
+ }
330
+
331
+ /** Why an offered build was not armed. Each is the policy working as intended;
332
+ * the distinction is logged and returned because the app's next move differs. */
333
+ export type DeclineReason =
334
+ | 'trial-pending'
335
+ | 'current'
336
+ | 'abandoned'
337
+ | 'exhausted'
338
+ | 'download-failed'
339
+ | 'install-failed'
340
+
341
+ /** The check-in never completed. */
342
+ export type CheckError =
343
+ | RequestError
344
+ | CborError
345
+ | {name: 'Status'; status: number}
346
+ /** The response body was past the size the client will buffer. */
347
+ | {name: 'TooLarge'; message: string}
348
+
349
+ export type CheckResult =
350
+ /** Build downloaded, verified, and armed. The app restarts when ready. */
351
+ | {status: 'staged'; offer: Offer}
352
+ /** `configUpdated` reports that the running build's stored config changed
353
+ * since the app could last have read it: delivered or cleared this round,
354
+ * or applied by this boot's install or rollback. An app that read config
355
+ * early in the cycle knows to read it again. */
356
+ | {status: 'up-to-date'; configUpdated?: boolean}
357
+ /** An offer arrived but was not armed. */
358
+ | {status: 'not-staged'; reason: DeclineReason; error?: OtaError}
359
+ /** Transient: the check-in did not complete, so the running trial (if any)
360
+ * was not confirmed. */
361
+ | {status: 'failed'; error: CheckError}
362
+ /** The registry rejected the update key (HTTP 401). Permanent until
363
+ * re-enrollment over the cable. */
364
+ | {status: 'unauthorized'}
365
+ | {status: 'not-enrolled'}