react-native-nitro-storage 0.13.0 → 0.15.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.
Files changed (69) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +260 -7
  3. package/cpp/bindings/HybridStorage.cpp +21 -0
  4. package/cpp/bindings/HybridStorage.hpp +2 -0
  5. package/cpp/core/NativeStorageAdapter.hpp +6 -0
  6. package/docs/api-reference.md +68 -43
  7. package/docs/batch-transactions-migrations.md +7 -0
  8. package/docs/qa/agent-device-replay.md +106 -0
  9. package/docs/secure-storage.md +12 -6
  10. package/ios/IOSStorageAdapterCpp.hpp +12 -0
  11. package/ios/IOSStorageAdapterCpp.mm +180 -2
  12. package/lib/commonjs/Storage.types.js +4 -2
  13. package/lib/commonjs/Storage.types.js.map +1 -1
  14. package/lib/commonjs/core/durability.js +30 -2
  15. package/lib/commonjs/core/durability.js.map +1 -1
  16. package/lib/commonjs/index.js +16 -1
  17. package/lib/commonjs/index.js.map +1 -1
  18. package/lib/commonjs/index.web.js +10 -1
  19. package/lib/commonjs/index.web.js.map +1 -1
  20. package/lib/commonjs/shared.js +6 -0
  21. package/lib/commonjs/shared.js.map +1 -1
  22. package/lib/commonjs/storage-core.js +116 -3
  23. package/lib/commonjs/storage-core.js.map +1 -1
  24. package/lib/commonjs/testing.js +32 -2
  25. package/lib/commonjs/testing.js.map +1 -1
  26. package/lib/module/Storage.types.js +4 -2
  27. package/lib/module/Storage.types.js.map +1 -1
  28. package/lib/module/core/durability.js +30 -2
  29. package/lib/module/core/durability.js.map +1 -1
  30. package/lib/module/index.js +16 -1
  31. package/lib/module/index.js.map +1 -1
  32. package/lib/module/index.web.js +10 -1
  33. package/lib/module/index.web.js.map +1 -1
  34. package/lib/module/shared.js +5 -0
  35. package/lib/module/shared.js.map +1 -1
  36. package/lib/module/storage-core.js +117 -4
  37. package/lib/module/storage-core.js.map +1 -1
  38. package/lib/module/testing.js +31 -1
  39. package/lib/module/testing.js.map +1 -1
  40. package/lib/typescript/Storage.nitro.d.ts +2 -0
  41. package/lib/typescript/Storage.nitro.d.ts.map +1 -1
  42. package/lib/typescript/Storage.types.d.ts +4 -2
  43. package/lib/typescript/Storage.types.d.ts.map +1 -1
  44. package/lib/typescript/core/durability.d.ts +7 -1
  45. package/lib/typescript/core/durability.d.ts.map +1 -1
  46. package/lib/typescript/index.d.ts +8 -3
  47. package/lib/typescript/index.d.ts.map +1 -1
  48. package/lib/typescript/index.web.d.ts +8 -3
  49. package/lib/typescript/index.web.d.ts.map +1 -1
  50. package/lib/typescript/shared.d.ts +9 -1
  51. package/lib/typescript/shared.d.ts.map +1 -1
  52. package/lib/typescript/storage-core.d.ts +34 -4
  53. package/lib/typescript/storage-core.d.ts.map +1 -1
  54. package/lib/typescript/storage-platform.d.ts +2 -0
  55. package/lib/typescript/storage-platform.d.ts.map +1 -1
  56. package/lib/typescript/testing.d.ts +16 -5
  57. package/lib/typescript/testing.d.ts.map +1 -1
  58. package/nitrogen/generated/shared/c++/HybridStorageSpec.cpp +2 -0
  59. package/nitrogen/generated/shared/c++/HybridStorageSpec.hpp +2 -0
  60. package/package.json +2 -2
  61. package/src/Storage.nitro.ts +2 -0
  62. package/src/Storage.types.ts +4 -2
  63. package/src/core/durability.ts +46 -2
  64. package/src/index.ts +24 -0
  65. package/src/index.web.ts +18 -0
  66. package/src/shared.ts +27 -1
  67. package/src/storage-core.ts +182 -8
  68. package/src/storage-platform.ts +2 -0
  69. package/src/testing.ts +42 -0
package/CHANGELOG.md CHANGED
@@ -6,6 +6,37 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
  Breaking changes are always listed first in each release section.
8
8
 
9
+ ## [0.15.0] - 2026-10-07
10
+
11
+ ### Breaking changes
12
+
13
+ None.
14
+
15
+ ### Added
16
+
17
+ - `migrateSecureAccessControl(level, options?)` sets the default Secure access control level and rewrites existing Secure items so each iOS Keychain item moves to that accessibility class. It reads each stored string and writes it back unchanged, and it never deletes a value. It returns `{ migrated, locked, missing, skipped, failed, enumerationLocked }`: a key whose read or write fails with `keychain_locked` stays untouched and is listed in `locked`; other failures are listed in `failed` with their error code; biometric-protected items, including a key whose biometric check is `keychain_locked`, are listed in `skipped` and are not read. It first runs `flushSecureWrites()`. When that flush or the listing of the Secure keys fails with `keychain_locked`, nothing changes, including the default level, and `enumerationLocked` is `true`; any other listing error is thrown with state unchanged. `options.keys` limits the keys to rewrite. On Android and web it validates the level, records the default, and lists every Secure key in `skipped`. Each rewrite emits a change event with an unchanged value. It runs in JavaScript on the existing native surface, so it works with a 0.14 native binary.
18
+ - `storage.isProtectedDataAvailable()` reports whether iOS protected data is available, from a cached value that is never read on the calling thread. The state starts unknown and unknown reports `false`; the first read on the main thread usually lands within milliseconds and fires the listeners when data is available. A cold background launch inside the lock grace window can read `true` until the app next becomes active, so treat `keychain_locked` and the success of the actual read as the source of truth in background-only code. The value is re-read when the app becomes active or enters the foreground. `storage.onProtectedDataAvailable(listener)` calls `listener` each time protected data becomes available again and returns an unsubscribe function. In an iOS app extension, where `UIApplication` is unavailable, the value is `true`. On Android and web the value is always `true` and the listener never fires. Both need the 0.15.0 native module: rebuild the app. On an older native binary `isProtectedDataAvailable()` returns `true` and `onProtectedDataAvailable` returns a no-op unsubscribe instead of throwing. Both are also on the `react-native-nitro-storage/testing` entry, where availability is `true` until `setMockProtectedDataAvailable(false)` simulates a locked device (`true` fires the listeners; `resetNitroStorageMock()` restores `true` and drops every `onProtectedDataAvailable` listener).
19
+ - New exported types: `SecureAccessControlMigrationOptions` and `SecureAccessControlMigrationResult`.
20
+
21
+ ### Documentation
22
+
23
+ - README explains the `setAccessControl` lifecycle (per process, call before the first Secure write, writes only, no effect on Android), when to run `migrateSecureAccessControl`, the protected-data APIs, and how to avoid `keychain_locked` by deferring Secure reads until protected data is available rather than weakening the access class. It compares `AfterFirstUnlock` and `AfterFirstUnlockThisDeviceOnly`, including backup and device-transfer behavior, and notes that raw `storage.getString` has no `fallbackToCacheOnReadError`. It warns that an app extension sharing the Keychain access group can race the read-then-rewrite in `migrateSecureAccessControl`; run it while extensions are idle.
24
+
25
+ ## [0.14.0] - 2026-10-03
26
+
27
+ ### Breaking changes
28
+
29
+ - Transaction and migration callbacks must finish synchronously. Promise and thenable results now throw `TypeError`, roll back writes made through the transaction context, and leave migration versions unchanged. Context methods reject calls after the callback ends. Migration: finish asynchronous work before entering the callback and keep all context access inside it. TypeScript now rejects direct async callbacks.
30
+
31
+ ### Added
32
+
33
+ - `storage.setScheduledFlushErrorObserver()` reports scheduled Disk and Secure flush failures with their scope and backend adapter error. Failed writes stay queued for retry. Explicit flush calls still throw to their caller, and scheduled failures still propagate when no observer is installed.
34
+
35
+ ### Documentation
36
+
37
+ - `flushSecureWrites()` drains the JavaScript queue; it is not an Android `apply()` durability barrier. Select synchronous secure-write mode before writes that need synchronous native persistence.
38
+ - Biometric policy documentation now distinguishes iOS Keychain prompts from Android authentication when a protected store first opens and its cached keyset on later access.
39
+
9
40
  ## [0.13.0] - 2026-10-01
10
41
 
11
42
  ### Breaking changes
package/README.md CHANGED
@@ -68,7 +68,7 @@ Nitro peer requirement: `react-native-nitro-modules >=0.37.0 <0.38.0`.
68
68
 
69
69
  | Tested on | Supported floor |
70
70
  | ------------------------------------------ | ----------------------------------- |
71
- | React Native `0.86.3` / Expo SDK `57.0.26` | React Native `0.77` / Expo SDK `53` |
71
+ | React Native `0.86.3` / Expo SDK `57.0.27` | React Native `0.77` / Expo SDK `53` |
72
72
 
73
73
  Nitro Storage supports React Native 0.77 or newer and Expo SDK 53 or newer,
74
74
  which is the minimum for Nitro Modules 0.37: its Android package does not
@@ -82,7 +82,7 @@ package exports are disabled (the default before React Native 0.79).
82
82
  The package gate uses React Native `0.86.3` and the Strict TypeScript API.
83
83
  `check:ci` also compiles the public source against React Native `0.87.0`'s
84
84
  Strict TypeScript API; this does not change the runtime baseline. The Expo
85
- example uses Expo SDK `57.0.26`, React Native
85
+ example uses Expo SDK `57.0.27`, React Native
86
86
  `0.86.3`, React `19.2.3`, and Nitro Modules `0.37.1`, which is the React Native
87
87
  version supported by that Expo SDK. Do not override Expo's React Native version.
88
88
 
@@ -91,7 +91,7 @@ before installing this package, then rebuild the native app so the generated
91
91
  Nitro bindings and native runtime use the same major-minor version:
92
92
 
93
93
  ```sh
94
- bun add react-native-nitro-modules@0.37.1 react-native-nitro-storage@0.13.0
94
+ bun add react-native-nitro-modules@0.37.1 react-native-nitro-storage@0.15.0
95
95
  bunx expo prebuild
96
96
  ```
97
97
 
@@ -491,9 +491,12 @@ explicitly opt into `{ includeSecureValues: true }`.
491
491
 
492
492
  Android secure writes default to synchronous `commit()` durability. Call
493
493
  `storage.setSecureWritesAsync(true)` only when asynchronous `apply()` writes are
494
- acceptable. After opting into async writes, call `storage.flushSecureWrites()`
495
- before a deterministic persistence boundary. A failed secure flush throws and
496
- keeps failed or unattempted queued writes available for retry.
494
+ acceptable. `storage.flushSecureWrites()` drains the JavaScript write queue into
495
+ the native backend; it does not wait for Android `apply()` to reach disk. Keep
496
+ the default synchronous mode, or call `storage.setSecureWritesAsync(false)`
497
+ before the writes that need synchronous native persistence. Changing the mode
498
+ does not make earlier `apply()` calls durable retroactively. A failed explicit
499
+ flush throws and keeps failed or unattempted queued writes available for retry.
497
500
  `storage.clearBiometric()`
498
501
  flushes pending Secure writes before clearing biometric entries and surfaces
499
502
  native clear failures.
@@ -538,6 +541,227 @@ Biometric behaviour differs by platform:
538
541
  before every read that must be gated. Reading a `BiometryOrPasscode` item
539
542
  opens only that store, so a device-credential authentication is enough.
540
543
 
544
+ Scheduled Disk and Secure flush failures can be handled without losing the
545
+ pending writes:
546
+
547
+ ```ts
548
+ import {
549
+ storage,
550
+ type StorageScheduledFlushError,
551
+ } from "react-native-nitro-storage";
552
+
553
+ let lastFlushFailure: StorageScheduledFlushError | undefined;
554
+ storage.setScheduledFlushErrorObserver((failure) => {
555
+ lastFlushFailure = failure;
556
+ });
557
+
558
+ // Call after resolving the failure, such as freeing space or unlocking storage.
559
+ function retryPendingWrites() {
560
+ storage.flushDiskWrites();
561
+ storage.flushSecureWrites();
562
+ lastFlushFailure = undefined;
563
+ }
564
+
565
+ // Remove the observer when its owner is disposed.
566
+ function stopObservingFlushErrors() {
567
+ storage.setScheduledFlushErrorObserver(undefined);
568
+ }
569
+ ```
570
+
571
+ The observer receives the failed scope and the error thrown by the backend
572
+ adapter. Web adapters may wrap the underlying failure in an error with `cause`.
573
+ It replaces
574
+ the uncaught scheduled-flush error only while installed; without an observer,
575
+ the error still propagates. Explicit flush calls always throw to their caller,
576
+ and an observer that throws also propagates its error. Register one observer per
577
+ storage instance and handle retry failures at the calling boundary. The observer
578
+ reports the synchronous backend handoff; it cannot report a later Android
579
+ `apply()` or IndexedDB persistence failure that the backend does not expose.
580
+
581
+ ### Access Control Lifecycle
582
+
583
+ `storage.setAccessControl(level)` sets the default `AccessControl` for Secure
584
+ writes. Follow these rules:
585
+
586
+ - It is per process and is not persisted. Call it on every launch, before the
587
+ first Secure write, for example at module scope in your app entry.
588
+ - It affects writes only. Reads never use the level, and an existing Keychain
589
+ item keeps its accessibility class until it is written again. On iOS every
590
+ Secure write, including an update of an existing item, sets
591
+ `kSecAttrAccessible`, so a rewrite moves the item to the current level.
592
+ - An item created with its own `accessControl` option uses that level instead
593
+ of the default.
594
+ - Android has no Keychain accessibility classes. `setAccessControl` only records
595
+ the JavaScript default there and changes no native behavior.
596
+ - Raw `storage.setString(key, value, StorageScope.Secure)` writes use the same
597
+ default level.
598
+
599
+ ```ts
600
+ import { AccessControl, storage } from "react-native-nitro-storage";
601
+
602
+ storage.setAccessControl(AccessControl.AfterFirstUnlockThisDeviceOnly);
603
+ ```
604
+
605
+ ### Migrating Existing Secure Items
606
+
607
+ Items written under `WhenUnlocked` stay unreadable while the device is locked,
608
+ even after you change the default. `migrateSecureAccessControl(level, options?)`
609
+ sets `level` as the default and rewrites each Secure item so the Keychain item
610
+ gets the new class. It reads the stored string and writes the same string back,
611
+ so values are never decoded, changed, or deleted.
612
+
613
+ ```ts
614
+ import {
615
+ AccessControl,
616
+ migrateSecureAccessControl,
617
+ } from "react-native-nitro-storage";
618
+
619
+ const result = migrateSecureAccessControl(
620
+ AccessControl.AfterFirstUnlockThisDeviceOnly,
621
+ );
622
+ // { migrated: string[]; locked: string[]; missing: string[];
623
+ // skipped: string[]; failed: { key: string; code?: StorageErrorCode }[];
624
+ // enumerationLocked: boolean }
625
+
626
+ if (result.locked.length > 0) {
627
+ // Retry only the locked keys once protected data is available.
628
+ migrateSecureAccessControl(AccessControl.AfterFirstUnlockThisDeviceOnly, {
629
+ keys: result.locked,
630
+ });
631
+ }
632
+ ```
633
+
634
+ - Run it while the device is unlocked, for example from a foreground user
635
+ action or after `storage.isProtectedDataAvailable()` returns `true`. Reading
636
+ or rewriting a `WhenUnlocked` item while locked fails with `keychain_locked`.
637
+ - It first runs `flushSecureWrites()` so queued Secure writes land before the
638
+ rewrite, then (without `options.keys`) lists the Secure keys. If the flush or
639
+ the listing fails with `keychain_locked`, it changes nothing, including the
640
+ default level, and returns `enumerationLocked: true` with empty lists (the
641
+ queued writes stay queued). Call it again once
642
+ protected data is available. Any other listing error is thrown and also
643
+ leaves the default level unchanged.
644
+ - A key whose read or write fails with `keychain_locked` is left unchanged and
645
+ listed in `locked`. Any other error leaves the key unchanged and lists it in
646
+ `failed` with its error code. The helper never deletes data.
647
+ - `options.keys` limits the work to specific keys. Without it, every Secure key
648
+ is processed. Biometric-protected items are listed in `skipped` and are never
649
+ read, because reading them can show a biometric prompt. A key whose biometric
650
+ check fails with `keychain_locked` is also listed in `skipped`, not `locked`:
651
+ biometric items are never migrated.
652
+ - The level becomes the default for later writes even when a key fails, so new
653
+ writes already use it.
654
+ - Items that you configured with their own `accessControl` option are rewritten
655
+ with `level` too. Pass `options.keys` to leave them out.
656
+ - On Android and web there is no accessibility class to change. The helper
657
+ validates the level, records the default, reads and writes nothing, and lists
658
+ every Secure key in `skipped`.
659
+ - Each rewrite is a native write, so storage listeners and observers receive a
660
+ change event for every migrated key, even though the value is unchanged.
661
+ - It reads each key and writes it back as separate steps. An app extension that
662
+ shares the Keychain access group and writes the same key between the two steps
663
+ can lose that write. Run the migration while extensions are idle.
664
+ - `migrateSecureAccessControl` is JavaScript only and works with any
665
+ 0.14-compatible native module. The protected-data APIs below need the 0.15.0
666
+ native module.
667
+ - Its result has `enumerationLocked: boolean` besides the key lists.
668
+
669
+ ### Protected Data Availability
670
+
671
+ On iOS, Keychain items in the `WhenUnlocked` classes (`WhenUnlocked`,
672
+ `WhenUnlockedThisDeviceOnly`, and `WhenPasscodeSetThisDeviceOnly`) can be read
673
+ only while protected data is available. Use these APIs to
674
+ wait instead of catching `keychain_locked`:
675
+
676
+ ```ts
677
+ import { storage } from "react-native-nitro-storage";
678
+
679
+ const available: boolean = storage.isProtectedDataAvailable();
680
+
681
+ const unsubscribe = storage.onProtectedDataAvailable(() => {
682
+ // Protected data became available again.
683
+ });
684
+ unsubscribe();
685
+ ```
686
+
687
+ - `isProtectedDataAvailable()` returns a cached value and never blocks. The
688
+ state starts unknown, and unknown reports `false`, so a locked device is never
689
+ reported as open. The first value is read from `UIApplication` on the main
690
+ thread: synchronously when the native module is created on the main thread,
691
+ otherwise asynchronously on the main queue, usually within a few
692
+ milliseconds. Until then the first calls can report `false` even when data is
693
+ available. When the read finds protected data available, listeners fire, so
694
+ gate on `onProtectedDataAvailable` instead of treating an early `false` as
695
+ final. iOS keeps the value current from the
696
+ `UIApplicationProtectedDataDidBecomeAvailable` and
697
+ `UIApplicationProtectedDataWillBecomeUnavailable` notifications, and
698
+ re-reads `UIApplication` when the app becomes active or enters the
699
+ foreground, because the available notification is not guaranteed when the
700
+ user unlocks inside the short window before protected data drops.
701
+ - Residual cases. A cold background launch that happens inside the grace
702
+ window after the device locks reads `true` until the app next becomes active,
703
+ because `UIApplication` still reports available during that window. A
704
+ background-only consumer can also keep a `false` cache after an unlock inside
705
+ the grace window until the next foreground. Treat `keychain_locked` and the
706
+ success of the actual read as the source of truth, not the cached value.
707
+ Before `UIApplication` exists (before `UIApplicationMain`), the state stays
708
+ unknown and reports `false` until the app becomes active.
709
+ - `onProtectedDataAvailable(listener)` calls `listener` each time protected data
710
+ changes from unavailable or unknown to available, and returns an unsubscribe
711
+ function. It does not call the listener on subscribe. Subscribe first, then
712
+ check `isProtectedDataAvailable()`, so a change between the two calls is not
713
+ missed.
714
+ - Both functions need the 0.15.0 native module. On an older native binary,
715
+ `isProtectedDataAvailable()` returns `true` and `onProtectedDataAvailable`
716
+ returns a no-op unsubscribe. Rebuild the app to get real values.
717
+ - In an iOS app extension `UIApplication` is not available. The module cannot
718
+ read the state there, so `isProtectedDataAvailable()` returns `true` and
719
+ `keychain_locked` remains the signal.
720
+ - Android and web always return `true`, and the listener never fires.
721
+
722
+ ### Handling keychain_locked
723
+
724
+ When a production app sees `keychain_locked` because Secure reads run while the
725
+ device is locked, for example during a background launch, defer those reads
726
+ until protected data is available. This keeps the default `WhenUnlocked`
727
+ protection.
728
+
729
+ ```ts
730
+ import { storage } from "react-native-nitro-storage";
731
+
732
+ export function whenProtectedDataAvailable(run: () => void): () => void {
733
+ let done = false;
734
+ const runOnce = () => {
735
+ if (done) return;
736
+ done = true;
737
+ unsubscribe();
738
+ run();
739
+ };
740
+ const unsubscribe = storage.onProtectedDataAvailable(runOnce);
741
+ if (storage.isProtectedDataAvailable()) runOnce();
742
+ return unsubscribe;
743
+ }
744
+ ```
745
+
746
+ Moving items to `AfterFirstUnlock` or `AfterFirstUnlockThisDeviceOnly` removes
747
+ the lock failure after the first unlock following a restart, but it weakens
748
+ protection: the items stay readable while the device is locked. Choose the
749
+ `ThisDeviceOnly` variant when items must not leave the device:
750
+
751
+ | Level | Readable while locked after first unlock | Included in backups and device transfer |
752
+ | -------------------------------- | ---------------------------------------- | --------------------------------------- |
753
+ | `WhenUnlocked` | No | Yes |
754
+ | `AfterFirstUnlock` | Yes | Yes |
755
+ | `AfterFirstUnlockThisDeviceOnly` | Yes | No |
756
+
757
+ Items in a `ThisDeviceOnly` class are not restored onto a new device from a
758
+ backup or device transfer, so the user must sign in again there.
759
+
760
+ Raw `storage.getString(key, StorageScope.Secure)` has no
761
+ `fallbackToCacheOnReadError`. It throws `keychain_locked` while the Keychain is
762
+ locked. Only typed items created with `fallbackToCacheOnReadError: true` return
763
+ their last cached value for that error.
764
+
541
765
  ## Batch Operations
542
766
 
543
767
  `getBatch()` preserves tuple value types, so IDEs infer each result from the
@@ -666,10 +890,16 @@ migrateFromMMKV(mmkvInstance, themeItem);
666
890
 
667
891
  `runTransaction(scope, callback)` rolls back every write made through the `tx`
668
892
  context if the callback throws, then emits one typed `rollback` batch event.
893
+ Callbacks must be synchronous. A Promise or thenable return throws a `TypeError`
894
+ and rolls back changes made through the context. Every context method becomes
895
+ unavailable when the callback ends, including for a continuation after `await`.
896
+ Complete asynchronous work before calling `runTransaction()` or registering a
897
+ migration; do not retain the context outside its callback.
669
898
 
670
899
  Each migration step runs in its own transaction with its version marker, so a
671
900
  failed step leaves the scope on the last completed version and rerunning
672
901
  `migrateToLatest()` retries deterministically.
902
+ An asynchronous migration is rejected without advancing its version marker.
673
903
 
674
904
  ## Web Backends
675
905
 
@@ -713,7 +943,10 @@ and Storybook run without native modules. Item subscribers and hooks re-render
713
943
  for Memory, Disk, and Secure writes. It does not model platform behaviour:
714
944
  there are no keychain locks, biometric prompts, access-control levels,
715
945
  coalesced native write timing, or web backends (the web backend functions are
716
- no-ops). Mock the package with it, or use it directly.
946
+ no-ops). Mock the package with it, or use it directly. To simulate a locked
947
+ device, call `setMockProtectedDataAvailable(false)`; `true` fires the
948
+ `onProtectedDataAvailable` listeners, and `resetNitroStorageMock()` restores
949
+ `true` and drops every `onProtectedDataAvailable` listener.
717
950
 
718
951
  ```ts
719
952
  import {
@@ -734,6 +967,18 @@ beforeEach(() => {
734
967
  const { storage, memoryItem } = createNitroStorageMock();
735
968
  ```
736
969
 
970
+ ```ts
971
+ import {
972
+ setMockProtectedDataAvailable,
973
+ storage,
974
+ } from "react-native-nitro-storage/testing";
975
+
976
+ const stop = storage.onProtectedDataAvailable(() => loadSession());
977
+ setMockProtectedDataAvailable(false); // storage.isProtectedDataAvailable() === false
978
+ setMockProtectedDataAvailable(true); // listener fires once
979
+ stop();
980
+ ```
981
+
737
982
  ## API
738
983
 
739
984
  The package exposes named `storage`, `createStorageItem`, the scoped item
@@ -850,6 +1095,7 @@ in a host measurement; devices are slower). Keep single Disk values below about
850
1095
  | Native libraries | [docs/native-libraries.md](docs/native-libraries.md) |
851
1096
  | Recipes | [docs/recipes.md](docs/recipes.md) |
852
1097
  | Benchmarks | [docs/benchmarks.md](docs/benchmarks.md) |
1098
+ | Example replay coverage | [docs/qa/agent-device-replay.md](docs/qa/agent-device-replay.md) |
853
1099
  | Security policy | [SECURITY.md](SECURITY.md) |
854
1100
 
855
1101
  ## Troubleshooting
@@ -887,6 +1133,13 @@ Nitro, secure storage, or packaging files. GitHub CI does not build the Android
887
1133
  or iOS example. The package release path also validates package contents and
888
1134
  dry-run publish behavior.
889
1135
 
1136
+ Run `bun run example:replay:check` to check that replay coverage matches package
1137
+ and example sources. After reviewing affected assertions, use
1138
+ `bun run example:replay:refresh` to update the source lock. Device execution uses
1139
+ `bun run example:replay --platform ios --udid <exact-target>` or
1140
+ `--platform android --serial <exact-target>`; see the
1141
+ [replay guide](docs/qa/agent-device-replay.md) for prerequisites and coverage limits.
1142
+
890
1143
  `bun run benchmark` measures only the built web entry with an isolated private
891
1144
  localStorage implementation; it is not a native Disk or Secure benchmark. See
892
1145
  [docs/benchmarks.md](docs/benchmarks.md) for sampling and interpretation limits.
@@ -535,6 +535,27 @@ void HybridStorage::clearSecureBiometric() {
535
535
  notifyListeners(static_cast<int>(Scope::Secure), kClearSentinelKey, std::nullopt);
536
536
  }
537
537
 
538
+ bool HybridStorage::isProtectedDataAvailable() {
539
+ ensureAdapter();
540
+ return runAdapterOperation(
541
+ [&] { return nativeAdapter_->isProtectedDataAvailable(); },
542
+ "Protected data availability");
543
+ }
544
+
545
+ std::function<void()> HybridStorage::onProtectedDataAvailable(const std::function<void()>& listener) {
546
+ ensureAdapter();
547
+ return runAdapterOperation(
548
+ [&] {
549
+ return nativeAdapter_->addProtectedDataAvailableListener([listener]() {
550
+ try {
551
+ listener();
552
+ } catch (...) {
553
+ }
554
+ });
555
+ },
556
+ "Protected data subscription");
557
+ }
558
+
538
559
  // --- Internal ---
539
560
 
540
561
  std::vector<HybridStorage::Listener> HybridStorage::copyListenersForScope(int scope) {
@@ -56,6 +56,8 @@ public:
56
56
  void deleteSecureBiometric(const std::string& key) override;
57
57
  bool hasSecureBiometric(const std::string& key) override;
58
58
  void clearSecureBiometric() override;
59
+ bool isProtectedDataAvailable() override;
60
+ std::function<void()> onProtectedDataAvailable(const std::function<void()>& listener) override;
59
61
 
60
62
  private:
61
63
  enum class Scope {
@@ -1,6 +1,7 @@
1
1
  #pragma once
2
2
 
3
3
  #include <cstddef>
4
+ #include <functional>
4
5
  #include <optional>
5
6
  #include <stdexcept>
6
7
  #include <string>
@@ -58,6 +59,11 @@ public:
58
59
  virtual void deleteSecureBiometric(const std::string& key) = 0;
59
60
  virtual bool hasSecureBiometric(const std::string& key) = 0;
60
61
  virtual void clearSecureBiometric() = 0;
62
+
63
+ virtual bool isProtectedDataAvailable() { return true; }
64
+ virtual std::function<void()> addProtectedDataAvailableListener(std::function<void()>) {
65
+ return []() {};
66
+ }
61
67
  };
62
68
 
63
69
  } // namespace NitroStorage
@@ -112,49 +112,52 @@ See [react-hooks.md](react-hooks.md).
112
112
 
113
113
  `storage` exposes raw and cross-item utilities:
114
114
 
115
- | Method | Purpose |
116
- | ------------------------------------------------ | ----------------------------------------------------------------------------------------- |
117
- | `clear(scope, options?)` | Clear one scope, optionally preserving selected keys. |
118
- | `clearAll()` | Clear Memory, Disk, and Secure scopes. |
119
- | `clearNamespace(namespace, scope)` | Remove keys under `namespace:`. |
120
- | `clearGroup(group)` | Remove registered items in a group across their scopes. |
121
- | `getGroupItems(group)` | List registered items in a group. |
122
- | `subscribeExpired(scope, listener)` | Receive item events caused by TTL expiry. |
123
- | `findDuplicateKeys()` | Find duplicate registered `(scope, key)` definitions. |
124
- | `getRegisteredKeys()` | List registered `(scope, key)` definitions. |
125
- | `subscribe(scope, listener)` | Subscribe to raw scope-level change events. |
126
- | `subscribeKey(scope, key, listener)` | Subscribe to raw events for one key. |
127
- | `subscribePrefix(scope, prefix, listener)` | Subscribe to raw events for matching key prefixes. |
128
- | `subscribeNamespace(namespace, scope, listener)` | Subscribe to raw events for `namespace:` keys. |
129
- | `setEventObserver(observer, options?)` | Receive all change events for devtools or logging. Secure values are redacted by default. |
130
- | `clearBiometric()` | Clear biometric Secure entries. |
131
- | `has(key, scope)` | Check for a raw key. |
132
- | `getAllKeys(scope)` | List raw keys. |
133
- | `getKeysByPrefix(prefix, scope)` | List raw keys with a prefix. |
134
- | `getByPrefix(prefix, scope)` | Read raw string values by prefix. |
135
- | `getAll(scope)` | Read all raw string values in a scope. |
136
- | `size(scope)` | Return approximate scope entry count. |
137
- | `setAccessControl(accessControl)` | Set the default Secure access control level. |
138
- | `setSecureWritesAsync(enabled)` | Toggle Android secure writes between sync and async modes. |
139
- | `setDiskWritesAsync(enabled)` | Toggle coalesced Disk write behavior. |
140
- | `flushDiskWrites()` | Flush pending Disk writes. |
141
- | `flushSecureWrites()` | Flush pending Secure writes. |
142
- | `setKeychainAccessGroup(group)` | Configure iOS Keychain access group. |
143
- | `setMetricsObserver(observer)` | Receive operation timing events. |
144
- | `getMetricsSnapshot()` | Read aggregated metrics. |
145
- | `getScopedMetricsSnapshot()` | Read metrics grouped by storage scope. |
146
- | `resetMetrics()` | Clear metrics counters. |
147
- | `getCacheMetrics()` | Read raw-cache hits, misses, live entries, and estimated bytes. |
148
- | `getCapabilities()` | Read runtime storage capabilities. Native `backend.disk` is `"sqlite"`. |
149
- | `getSecurityCapabilities()` | Read secure backend capability metadata. |
150
- | `getSecureMetadata(key)` | Read secure metadata for one key without returning its value. |
151
- | `getAllSecureMetadata()` | Read secure metadata for all secure keys without values. |
152
- | `getString(key, scope)` | Read a raw string. |
153
- | `setString(key, value, scope)` | Write a raw string. |
154
- | `deleteString(key, scope)` | Remove a raw key. |
155
- | `export(scope, options?)` | Snapshot raw strings from one scope. Secure scope requires explicit unsafe opt-in. |
156
- | `exportSecureUnsafe()` | Snapshot raw Secure strings for short-lived migration workflows. |
157
- | `import(data, scope)` | Bulk import raw strings. |
115
+ | Method | Purpose |
116
+ | ------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
117
+ | `clear(scope, options?)` | Clear one scope, optionally preserving selected keys. |
118
+ | `clearAll()` | Clear Memory, Disk, and Secure scopes. |
119
+ | `clearNamespace(namespace, scope)` | Remove keys under `namespace:`. |
120
+ | `clearGroup(group)` | Remove registered items in a group across their scopes. |
121
+ | `getGroupItems(group)` | List registered items in a group. |
122
+ | `subscribeExpired(scope, listener)` | Receive item events caused by TTL expiry. |
123
+ | `findDuplicateKeys()` | Find duplicate registered `(scope, key)` definitions. |
124
+ | `getRegisteredKeys()` | List registered `(scope, key)` definitions. |
125
+ | `subscribe(scope, listener)` | Subscribe to raw scope-level change events. |
126
+ | `subscribeKey(scope, key, listener)` | Subscribe to raw events for one key. |
127
+ | `subscribePrefix(scope, prefix, listener)` | Subscribe to raw events for matching key prefixes. |
128
+ | `subscribeNamespace(namespace, scope, listener)` | Subscribe to raw events for `namespace:` keys. |
129
+ | `setEventObserver(observer, options?)` | Receive all change events for devtools or logging. Secure values are redacted by default. |
130
+ | `clearBiometric()` | Clear biometric Secure entries. |
131
+ | `has(key, scope)` | Check for a raw key. |
132
+ | `getAllKeys(scope)` | List raw keys. |
133
+ | `getKeysByPrefix(prefix, scope)` | List raw keys with a prefix. |
134
+ | `getByPrefix(prefix, scope)` | Read raw string values by prefix. |
135
+ | `getAll(scope)` | Read all raw string values in a scope. |
136
+ | `size(scope)` | Return approximate scope entry count. |
137
+ | `setAccessControl(accessControl)` | Set the default Secure access control level. |
138
+ | `isProtectedDataAvailable()` | Read cached iOS protected-data availability; may report `false` briefly until the first read lands. Android, web, and 0.14 native binaries return `true`. |
139
+ | `onProtectedDataAvailable(listener)` | Call `listener` when protected data becomes available (including the first known-available read); returns an unsubscribe function. Android and web never fire. |
140
+ | `setSecureWritesAsync(enabled)` | Toggle Android secure writes between sync and async modes. |
141
+ | `setDiskWritesAsync(enabled)` | Toggle coalesced Disk write behavior. |
142
+ | `flushDiskWrites()` | Flush pending Disk writes. |
143
+ | `flushSecureWrites()` | Drain queued Secure writes into the backend; not an Android `apply()` persistence barrier. |
144
+ | `setScheduledFlushErrorObserver(observer)` | Observe scheduled Disk/Secure flush failures; pass `undefined` to restore uncaught failures. Explicit flush calls still throw. |
145
+ | `setKeychainAccessGroup(group)` | Configure iOS Keychain access group. |
146
+ | `setMetricsObserver(observer)` | Receive operation timing events. |
147
+ | `getMetricsSnapshot()` | Read aggregated metrics. |
148
+ | `getScopedMetricsSnapshot()` | Read metrics grouped by storage scope. |
149
+ | `resetMetrics()` | Clear metrics counters. |
150
+ | `getCacheMetrics()` | Read raw-cache hits, misses, live entries, and estimated bytes. |
151
+ | `getCapabilities()` | Read runtime storage capabilities. Native `backend.disk` is `"sqlite"`. |
152
+ | `getSecurityCapabilities()` | Read secure backend capability metadata. |
153
+ | `getSecureMetadata(key)` | Read secure metadata for one key without returning its value. |
154
+ | `getAllSecureMetadata()` | Read secure metadata for all secure keys without values. |
155
+ | `getString(key, scope)` | Read a raw string. |
156
+ | `setString(key, value, scope)` | Write a raw string. |
157
+ | `deleteString(key, scope)` | Remove a raw key. |
158
+ | `export(scope, options?)` | Snapshot raw strings from one scope. Secure scope requires explicit unsafe opt-in. |
159
+ | `exportSecureUnsafe()` | Snapshot raw Secure strings for short-lived migration workflows. |
160
+ | `import(data, scope)` | Bulk import raw strings. |
158
161
 
159
162
  Raw string APIs bypass item serialization and validation. Prefer `StorageItem<T>` unless you are migrating, exporting/importing, or writing a custom integration.
160
163
 
@@ -234,6 +237,9 @@ runTransaction(StorageScope.Disk, (tx) => {
234
237
  ```
235
238
 
236
239
  If the callback throws, previously changed keys in that transaction are rolled back synchronously.
240
+ Promise or thenable results are rejected with `TypeError` and rollback. Context
241
+ methods are valid only during the synchronous callback. Finish awaited work
242
+ before entering the transaction.
237
243
 
238
244
  ## Migrations
239
245
 
@@ -249,6 +255,14 @@ migrateToLatest(StorageScope.Disk);
249
255
  ```
250
256
 
251
257
  Migration versions are tracked per scope.
258
+ Migration callbacks must be synchronous. An async callback is rejected without
259
+ advancing its version, and the next migration run retries that step.
260
+
261
+ ## Secure Access Control Migration
262
+
263
+ | API | Purpose |
264
+ | --------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
265
+ | `migrateSecureAccessControl(level, options?)` | Set the default Secure access control level and rewrite each Secure item so its iOS Keychain item moves to that class. Returns `{ migrated, locked, missing, skipped, failed, enumerationLocked }`. It runs `flushSecureWrites()` first. `enumerationLocked: true` means that flush or listing the Secure keys failed with `keychain_locked`: nothing was changed, including the default level, and queued writes stay queued; retry later. Other enumeration errors are thrown with state unchanged. `options.keys` limits the keys. JavaScript only; Android and web record the default and list every key in `skipped`. |
252
266
 
253
267
  ## Secure Auth Storage
254
268
 
@@ -313,6 +327,15 @@ function as typed no-ops for shared code.
313
327
 
314
328
  See [web-backends.md](web-backends.md).
315
329
 
330
+ ## Testing Entry
331
+
332
+ `react-native-nitro-storage/testing` exports an in-memory implementation of the public surface.
333
+
334
+ | API | Description |
335
+ | ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
336
+ | `setMockProtectedDataAvailable(available)` | Set the mock protected-data availability (default `true`). `false` simulates a locked device; `true` after `false` fires the `onProtectedDataAvailable` listeners. Throws a `TypeError` for a non-boolean. |
337
+ | `resetNitroStorageMock()` | Clear all mock data and observers, restore protected-data availability to `true`, and drop every `onProtectedDataAvailable` listener. |
338
+
316
339
  ## Enums
317
340
 
318
341
  ```ts
@@ -342,6 +365,8 @@ enum BiometricLevel {
342
365
  Common public types:
343
366
 
344
367
  - `Storage`
368
+ - `SecureAccessControlMigrationOptions`
369
+ - `SecureAccessControlMigrationResult`
345
370
  - `Validator<T>`
346
371
  - `ExpirationConfig`
347
372
  - `StorageItem<T>`
@@ -126,6 +126,11 @@ Transaction context methods:
126
126
 
127
127
  If the callback throws, Nitro Storage restores the keys it changed during that transaction and emits exactly one typed `rollback` batch event so observers and the global event observer can react to the rollback.
128
128
 
129
+ Callbacks must return synchronously. A Promise or thenable result throws a
130
+ `TypeError` and triggers the same rollback. Context methods reject access after
131
+ the callback finishes, including from an async continuation. Complete awaited
132
+ work before the transaction and never retain its context for later use.
133
+
129
134
  Memory-scope batch removes are atomic: all keys are deleted before listeners fire, and scope, key, and prefix subscribers receive a single `removeBatch` event.
130
135
 
131
136
  ## Migrations
@@ -160,6 +165,8 @@ migrateToLatest(StorageScope.Disk);
160
165
  Migration context methods work with raw strings. Use item serializers manually when migrating structured data.
161
166
 
162
167
  Each migration step runs inside its own transaction together with the version marker write. A step that throws rolls back both its data changes and the version marker, so the scope stays on the last completed version and rerunning `migrateToLatest()` retries deterministically.
168
+ Migration callbacks follow the same synchronous contract as transactions. An
169
+ async migration does not advance its version marker.
163
170
 
164
171
  ```ts
165
172
  registerMigration(3, (ctx) => {