@scalebun/react-native 1.11.0 → 1.13.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.
@@ -8,42 +8,38 @@
8
8
 
9
9
  /**
10
10
  * ═══════════════════════════════════════════════════════════════════════════
11
- * OTA-TELEMETRY-SPEC — remaining telemetry fixes (from the 2026-08 audit)
11
+ * OTA-TELEMETRY-SPEC — status (spec §84-86)
12
12
  * ═══════════════════════════════════════════════════════════════════════════
13
- * These close the SDK half of spec §84-86. All are ADDITIVE + backward-compatible
14
- * (new optional fields, new enum members, new no-throw emit calls); old installed
15
- * clients omit them and the backend ingests nullable/unknown fields. Each ALTERS
16
- * DEVICE RUNTIME BEHAVIOR, so land them behind a real RN build + device/kill-test
17
- * (Metro cannot exercise the boot-guard) — this file only carries the contract
18
- * types + this spec, not the runtime wiring.
13
+ * Items 1-3 are DONE. All were additive and backward-compatible: old installed
14
+ * clients simply omit the new fields and never emit the new types, and the
15
+ * backend ingests nullable columns. Because they alter device runtime behaviour,
16
+ * verify against a real RN build + device kill-test before release — Metro
17
+ * cannot exercise the boot guard.
19
18
  *
20
- * 1. releaseId end-to-end (CRITICALroot of the dashboard funnel mismatch)
21
- * Backend already serves it: OtaBundlePayload.releaseId (done, this file +
22
- * ota-check.service.ts). SDK TODO:
23
- * - store payload.releaseId on the downloaded/installed bundle state, and
24
- * - set `releaseId: bundle.releaseId` in the delivery mapper
25
- * OtaOrchestrator.ts deliverOtaEvents (~:105) — currently only bundleId.
26
- * Then ota_events carry releaseId and the funnel keys by release.
19
+ * 1. releaseId end-to-end — DONE. `recordInstallExpectation` persists the
20
+ * release id (so a rollback reported launches later can still name it),
21
+ * `hydrateCurrentBundleFromSlots` restores it, every emit site passes it, and
22
+ * `deliverOtaEvents` maps it onto the wire item.
27
23
  *
28
- * 2. Emit CHECK + OFFERED (funnel top is currently unmeasurable)
29
- * - CHECK: emit at the start of checkForUpdate (OtaOrchestrator.ts ~:480),
30
- * before the fetch. ('CHECK' type already existsno emit site today.)
31
- * - OFFERED: add 'OFFERED' to OtaEventType, emit when checkRes.action ===
32
- * 'DOWNLOAD' (~:619) before download begins.
24
+ * 2. CHECK + OFFERED DONE. CHECK is emitted at the top of `sync()` for devices
25
+ * already on an OTA bundle (a device on the factory bundle has no owned
26
+ * bundle id to name, and the server drops rows it cannot resolve those are
27
+ * counted server-side instead). OFFERED is emitted on `action === 'DOWNLOAD'`
28
+ * before any bytes move.
33
29
  *
34
- * 3. Emit BOOT_SUCCESS (honest activation signal)
35
- * Add 'BOOT_SUCCESS' to OtaEventType and emit it from the boot-guard
36
- * markHealthy path (OtaOrchestrator.ts ~:863). INSTALLED (~:794) is emitted
37
- * optimistically BEFORE the bundle boots; keep it (it means "staged+swapped")
38
- * but let the dashboard measure real activation on BOOT_SUCCESS. Optionally
39
- * add 'VERIFIED' after signature check (~:635).
30
+ * 3. BOOT_SUCCESS DONE. Emitted from the boot-guard heartbeat once the bundle
31
+ * has actually booted and survived to `healthyAfterMs`. INSTALLED is kept and
32
+ * still means "staged + swapped", but activation should be measured on
33
+ * BOOT_SUCCESS: INSTALLED fires before the bundle has run, so it credits
34
+ * bundles that installed and then crash-reverted.
40
35
  *
41
- * 4. Stamp the running OTA bundle onto session/crash telemetry
42
- * SessionMetadata.bundleId is the NATIVE app package id, not the OTA bundle.
43
- * Add optional otaBundleId?/otaBundleVersion? (distinct fields do NOT
44
- * overload bundleId) sourced from otaOrchestrator.getCurrentBundle() at
45
- * session start (SessionManager.ts ~:451), so crashes attribute to the
46
- * running bundle/version (release-health crash impact).
36
+ * 4. Stamp the running OTA bundle onto session/crash telemetry — NOT DONE.
37
+ * `SessionMetadata.bundleId` is the NATIVE app package id, not the OTA bundle,
38
+ * so crashes cannot currently be attributed to the bundle that produced them
39
+ * (release-health crash impact). Needs optional otaBundleId?/otaBundleVersion?
40
+ * (distinct fields do NOT overload bundleId) sourced from
41
+ * `otaOrchestrator.getCurrentBundle()` at session start, plus the matching
42
+ * backend columns and a Prisma migration.
47
43
  *
48
44
  * Do NOT repurpose errorCode (it already collapses failure-error vs rollback-
49
45
  * reason); add a new optional field if the two must be distinguished.
@@ -149,14 +145,16 @@ export interface OtaPatchPayload {
149
145
  }
150
146
 
151
147
  // ── Adoption telemetry (rides the unified batch envelope as kind:'ota_event') ──
152
- export type OtaEventType =
153
- | 'CHECK'
154
- | 'DOWNLOAD_STARTED'
155
- | 'DOWNLOAD_COMPLETE'
156
- | 'INSTALLED'
157
- | 'APPLY_FAILED'
158
- | 'AUTO_ROLLBACK'
159
- | 'MANUAL_ROLLBACK';
148
+ //
149
+ // The event vocabulary is declared ONCE, in OtaEventEmitter — the module that
150
+ // emits it. This file used to carry a second, shorter copy under the same name,
151
+ // so `OtaEventItem.type` and the emitter's events could disagree while both
152
+ // type-checked, and adding a funnel step meant remembering to edit two unions.
153
+ // `DOWNLOAD_PROGRESS` is emitted for UI only and never queued for upload, so it
154
+ // is excluded here rather than re-listing the rest.
155
+ import type { OtaEventType as EmittedOtaEventType } from './OtaEventEmitter';
156
+
157
+ export type OtaEventType = Exclude<EmittedOtaEventType, 'DOWNLOAD_PROGRESS'>;
160
158
 
161
159
  export interface OtaEventItem {
162
160
  kind: 'ota_event';
@@ -115,7 +115,13 @@ export function useOtaUpdate(options: UseOtaUpdateOptions): UseOtaUpdateReturn {
115
115
  setIsSyncing(true);
116
116
  setDownloadProgress(0);
117
117
  try {
118
- const result = await otaOrchestrator.sync(options);
118
+ // Read through the ref, not the closed-over `options`. The dependency list
119
+ // below cannot name the targeting fields (`attributes` and `segmentIds` are
120
+ // fresh object/array identities on every render, so listing them would
121
+ // rebuild this callback each time), which left a host that changed
122
+ // `channelName` or `lifecycleStage` syncing against the values captured on
123
+ // first render. The ref is assigned on every render, so it is always current.
124
+ const result = await otaOrchestrator.sync(optionsRef.current);
119
125
  setSyncResult(result);
120
126
 
121
127
  // Handle mandatory update blocking
@@ -131,7 +137,10 @@ export function useOtaUpdate(options: UseOtaUpdateOptions): UseOtaUpdateReturn {
131
137
  } finally {
132
138
  setIsSyncing(false);
133
139
  }
134
- }, [options.apiUrl, options.clientKey, options.appVersion, options.installationId, options.autoRestart]);
140
+ // Empty: everything this reads comes from `optionsRef`, so the callback has a
141
+ // stable identity and never needs rebuilding. A host can safely pass it to a
142
+ // memoised child or an effect dependency list.
143
+ }, []);
135
144
 
136
145
  const restart = useCallback(() => {
137
146
  setMandatoryUpdatePending(false);
@@ -327,6 +327,17 @@ class ScaleBunFacade {
327
327
  );
328
328
  }
329
329
  }
330
+ // `healthyAfterMs` is the field BootGuardConfig actually declares.
331
+ // This passed `healthyTimeoutMs`, which nothing reads, so a host that
332
+ // tuned the boot-guard window silently got the 10s default instead.
333
+ // Both spellings are accepted so the older one keeps working.
334
+ const healthyAfterMs =
335
+ typeof ota.healthyAfterMs === 'number'
336
+ ? ota.healthyAfterMs
337
+ : typeof ota.healthyTimeoutMs === 'number'
338
+ ? ota.healthyTimeoutMs
339
+ : undefined;
340
+
330
341
  otaOrchestrator.init({
331
342
  ...(signingRequested
332
343
  ? {
@@ -338,16 +349,129 @@ class ScaleBunFacade {
338
349
  },
339
350
  }
340
351
  : {}),
341
- ...(typeof ota.healthyTimeoutMs === 'number'
342
- ? { healthyTimeoutMs: ota.healthyTimeoutMs }
343
- : {}),
352
+ ...(healthyAfterMs !== undefined ? { healthyAfterMs } : {}),
344
353
  });
345
354
  logger.info('[ScaleBun] OTA enabled from init config.');
355
+
356
+ // Run the checks the config asked for. Until this existed, `ota.enabled`
357
+ // initialised the orchestrator and then never checked anything: the
358
+ // documented `checkOnForeground` and `channelOverride` options were read
359
+ // by no code at all, and an app following the documented config received
360
+ // updates only if it ALSO drove `useOtaUpdate` or the CodePush shim by
361
+ // hand. Nothing logged, because "no update available" and "never asked"
362
+ // look identical from the outside.
363
+ this._startOtaChecks(ota);
346
364
  } catch (err: any) {
347
365
  logger.warn(`[ScaleBun] OTA init failed: ${err?.message ?? err}`);
348
366
  }
349
367
  }
350
368
 
369
+ /** Guards against overlapping config-driven OTA checks. */
370
+ private _otaCheckInFlight = false;
371
+ /** Wall clock of the last config-driven check, for the foreground floor. */
372
+ private _otaLastCheckAt = 0;
373
+ private _otaForegroundListener: ((state: string) => void) | null = null;
374
+
375
+ /**
376
+ * Minimum gap between config-driven checks.
377
+ *
378
+ * A foreground transition is cheap to trigger — app switchers, permission
379
+ * dialogs and share sheets all produce one — so an unthrottled check would
380
+ * put a request on the hot path every time the user glanced away. Ten
381
+ * minutes is well below any realistic release cadence and well above that
382
+ * noise. A host that wants a check on demand calls `useOtaUpdate().sync()`,
383
+ * which is never throttled.
384
+ */
385
+ private static readonly OTA_MIN_CHECK_INTERVAL_MS = 10 * 60 * 1000;
386
+
387
+ /**
388
+ * Drive OTA checks from init config: once at startup, then on each
389
+ * foreground when `checkOnForeground` is on (the schema default).
390
+ *
391
+ * `appVersion` is resolved from the native bridge rather than asked of the
392
+ * integrator, because it gates the server's `targetAppVersion` semver check
393
+ * — sending a wrong or invented value is worse than sending none, and there
394
+ * is no honest default. If it cannot be resolved, the check is skipped with
395
+ * a warning instead of guessing.
396
+ */
397
+ private _startOtaChecks(ota: any): void {
398
+ // Never in a debug build. `ScaleBunOtaModule.getJSBundleFile()` returns null
399
+ // there on purpose so Metro keeps ownership of the bundle — so a bundle
400
+ // downloaded in dev is installed into a slot that will never be loaded, and
401
+ // the identity check on the next launch then correctly observes that the
402
+ // running code is not what was installed and reports APPLY_FAILED. Checking
403
+ // at all in dev buys nothing and manufactures that false alarm. A developer
404
+ // testing the OTA path drives `useOtaUpdate().sync()` explicitly.
405
+ if (__DEV__) {
406
+ logger.info('[ScaleBun] OTA checks are skipped in debug builds (Metro owns the bundle).');
407
+ return;
408
+ }
409
+
410
+ const runCheck = async (trigger: 'startup' | 'foreground'): Promise<void> => {
411
+ if (this._otaCheckInFlight) return;
412
+ if (
413
+ trigger === 'foreground' &&
414
+ Date.now() - this._otaLastCheckAt < ScaleBunFacade.OTA_MIN_CHECK_INTERVAL_MS
415
+ ) {
416
+ return;
417
+ }
418
+ const clientKey = this._clientKey;
419
+ const apiUrl = this._apiBaseUrl;
420
+ if (!clientKey || !apiUrl) return;
421
+
422
+ this._otaCheckInFlight = true;
423
+ try {
424
+ const info = await bridgeAdapter.getDeviceInfo();
425
+ const appVersion = (info as { appVersion?: string } | null)?.appVersion;
426
+ if (!appVersion) {
427
+ logger.warn(
428
+ '[ScaleBun] OTA check skipped — the app version could not be read from the ' +
429
+ 'native bridge. Rebuild the native app, or drive checks yourself with ' +
430
+ 'useOtaUpdate({ appVersion }).',
431
+ );
432
+ return;
433
+ }
434
+ this._otaLastCheckAt = Date.now();
435
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
436
+ const { otaOrchestrator } = require('../features/ota/OtaOrchestrator');
437
+ await otaOrchestrator.sync({
438
+ apiUrl,
439
+ clientKey,
440
+ appVersion,
441
+ // The documented option, finally connected. Omitted means the
442
+ // server's `default` channel, exactly as before.
443
+ channelName: typeof ota.channelOverride === 'string' ? ota.channelOverride : undefined,
444
+ // Never forced from config: the release's own installMode
445
+ // decides when the app restarts, and yanking the screen out
446
+ // from under a user is not a decision this switch should make.
447
+ });
448
+ } catch (err: any) {
449
+ logger.warn(`[ScaleBun] OTA check failed: ${err?.message ?? err}`);
450
+ } finally {
451
+ this._otaCheckInFlight = false;
452
+ }
453
+ };
454
+
455
+ void runCheck('startup');
456
+
457
+ if (ota.checkOnForeground === false) return;
458
+ if (this._otaForegroundListener) return; // idempotent across repeated init()
459
+ try {
460
+ // Through `appLifecycle`, not a second AppState subscription: the SDK
461
+ // already owns one and fanning out from it keeps every consumer on the
462
+ // same transition sequence.
463
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
464
+ const { appLifecycle } = require('../core/lifecycle/appLifecycle');
465
+ this._otaForegroundListener = (state: string) => {
466
+ if (state === 'active') void runCheck('foreground');
467
+ };
468
+ appLifecycle.addListener(this._otaForegroundListener);
469
+ } catch {
470
+ // Lifecycle unavailable (tests, exotic hosts) — the startup check stands.
471
+ this._otaForegroundListener = null;
472
+ }
473
+ }
474
+
351
475
  private _autoEnableDebug(debugConfig: DebugConnectionConfig): void {
352
476
  try {
353
477
  const dbgConfig = buildDebugConfigFromInitConfig(debugConfig);