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.
- package/CHANGELOG.md +31 -0
- package/README.md +260 -7
- package/cpp/bindings/HybridStorage.cpp +21 -0
- package/cpp/bindings/HybridStorage.hpp +2 -0
- package/cpp/core/NativeStorageAdapter.hpp +6 -0
- package/docs/api-reference.md +68 -43
- package/docs/batch-transactions-migrations.md +7 -0
- package/docs/qa/agent-device-replay.md +106 -0
- package/docs/secure-storage.md +12 -6
- package/ios/IOSStorageAdapterCpp.hpp +12 -0
- package/ios/IOSStorageAdapterCpp.mm +180 -2
- package/lib/commonjs/Storage.types.js +4 -2
- package/lib/commonjs/Storage.types.js.map +1 -1
- package/lib/commonjs/core/durability.js +30 -2
- package/lib/commonjs/core/durability.js.map +1 -1
- package/lib/commonjs/index.js +16 -1
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/index.web.js +10 -1
- package/lib/commonjs/index.web.js.map +1 -1
- package/lib/commonjs/shared.js +6 -0
- package/lib/commonjs/shared.js.map +1 -1
- package/lib/commonjs/storage-core.js +116 -3
- package/lib/commonjs/storage-core.js.map +1 -1
- package/lib/commonjs/testing.js +32 -2
- package/lib/commonjs/testing.js.map +1 -1
- package/lib/module/Storage.types.js +4 -2
- package/lib/module/Storage.types.js.map +1 -1
- package/lib/module/core/durability.js +30 -2
- package/lib/module/core/durability.js.map +1 -1
- package/lib/module/index.js +16 -1
- package/lib/module/index.js.map +1 -1
- package/lib/module/index.web.js +10 -1
- package/lib/module/index.web.js.map +1 -1
- package/lib/module/shared.js +5 -0
- package/lib/module/shared.js.map +1 -1
- package/lib/module/storage-core.js +117 -4
- package/lib/module/storage-core.js.map +1 -1
- package/lib/module/testing.js +31 -1
- package/lib/module/testing.js.map +1 -1
- package/lib/typescript/Storage.nitro.d.ts +2 -0
- package/lib/typescript/Storage.nitro.d.ts.map +1 -1
- package/lib/typescript/Storage.types.d.ts +4 -2
- package/lib/typescript/Storage.types.d.ts.map +1 -1
- package/lib/typescript/core/durability.d.ts +7 -1
- package/lib/typescript/core/durability.d.ts.map +1 -1
- package/lib/typescript/index.d.ts +8 -3
- package/lib/typescript/index.d.ts.map +1 -1
- package/lib/typescript/index.web.d.ts +8 -3
- package/lib/typescript/index.web.d.ts.map +1 -1
- package/lib/typescript/shared.d.ts +9 -1
- package/lib/typescript/shared.d.ts.map +1 -1
- package/lib/typescript/storage-core.d.ts +34 -4
- package/lib/typescript/storage-core.d.ts.map +1 -1
- package/lib/typescript/storage-platform.d.ts +2 -0
- package/lib/typescript/storage-platform.d.ts.map +1 -1
- package/lib/typescript/testing.d.ts +16 -5
- package/lib/typescript/testing.d.ts.map +1 -1
- package/nitrogen/generated/shared/c++/HybridStorageSpec.cpp +2 -0
- package/nitrogen/generated/shared/c++/HybridStorageSpec.hpp +2 -0
- package/package.json +2 -2
- package/src/Storage.nitro.ts +2 -0
- package/src/Storage.types.ts +4 -2
- package/src/core/durability.ts +46 -2
- package/src/index.ts +24 -0
- package/src/index.web.ts +18 -0
- package/src/shared.ts +27 -1
- package/src/storage-core.ts +182 -8
- package/src/storage-platform.ts +2 -0
- 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.
|
|
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.
|
|
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.
|
|
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.
|
|
495
|
-
|
|
496
|
-
|
|
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
|
package/docs/api-reference.md
CHANGED
|
@@ -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
|
-
| `
|
|
139
|
-
| `
|
|
140
|
-
| `
|
|
141
|
-
| `
|
|
142
|
-
| `
|
|
143
|
-
| `
|
|
144
|
-
| `
|
|
145
|
-
| `
|
|
146
|
-
| `
|
|
147
|
-
| `
|
|
148
|
-
| `
|
|
149
|
-
| `
|
|
150
|
-
| `
|
|
151
|
-
| `
|
|
152
|
-
| `
|
|
153
|
-
| `
|
|
154
|
-
| `
|
|
155
|
-
| `
|
|
156
|
-
| `
|
|
157
|
-
| `
|
|
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) => {
|