react-native-nitro-storage 0.10.3 → 0.11.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 (87) hide show
  1. package/CHANGELOG.md +85 -14
  2. package/README.md +148 -41
  3. package/SECURITY.md +16 -6
  4. package/android/build.gradle +0 -1
  5. package/android/consumer-rules.pro +0 -3
  6. package/android/src/main/cpp/AndroidStorageAdapterCpp.cpp +48 -33
  7. package/android/src/main/cpp/AndroidStorageAdapterCpp.hpp +3 -3
  8. package/android/src/main/java/com/nitrostorage/AndroidStorageAdapter.kt +73 -23
  9. package/android/src/main/java/com/nitrostorage/DiskSqliteStore.kt +22 -4
  10. package/app.plugin.js +51 -51
  11. package/cpp/bindings/HybridStorage.cpp +25 -6
  12. package/cpp/core/NativeStorageAdapter.hpp +14 -1
  13. package/cpp/core/SqliteDiskStore.cpp +94 -17
  14. package/cpp/core/SqliteDiskStore.hpp +10 -0
  15. package/docs/api-reference.md +73 -28
  16. package/docs/benchmarks.md +41 -12
  17. package/docs/mmkv-migration.md +3 -1
  18. package/docs/native-libraries.md +10 -4
  19. package/docs/secure-storage.md +26 -12
  20. package/docs/web-backends.md +6 -2
  21. package/indexeddb-backend/package.json +6 -0
  22. package/ios/IOSStorageAdapterCpp.hpp +4 -0
  23. package/ios/IOSStorageAdapterCpp.mm +149 -57
  24. package/lib/commonjs/capabilities.js +3 -8
  25. package/lib/commonjs/capabilities.js.map +1 -1
  26. package/lib/commonjs/index.js +23 -8
  27. package/lib/commonjs/index.js.map +1 -1
  28. package/lib/commonjs/index.web.js +61 -19
  29. package/lib/commonjs/index.web.js.map +1 -1
  30. package/lib/commonjs/indexeddb-backend.js +21 -37
  31. package/lib/commonjs/indexeddb-backend.js.map +1 -1
  32. package/lib/commonjs/internal.js +6 -0
  33. package/lib/commonjs/internal.js.map +1 -1
  34. package/lib/commonjs/storage-core.js +61 -22
  35. package/lib/commonjs/storage-core.js.map +1 -1
  36. package/lib/commonjs/storage-runtime.js +1 -1
  37. package/lib/commonjs/storage-runtime.js.map +1 -1
  38. package/lib/commonjs/testing.js +68 -23
  39. package/lib/commonjs/testing.js.map +1 -1
  40. package/lib/commonjs/web-backend-contract.js +6 -2
  41. package/lib/commonjs/web-backend-contract.js.map +1 -1
  42. package/lib/module/capabilities.js +3 -8
  43. package/lib/module/capabilities.js.map +1 -1
  44. package/lib/module/index.js +14 -5
  45. package/lib/module/index.js.map +1 -1
  46. package/lib/module/index.web.js +61 -12
  47. package/lib/module/index.web.js.map +1 -1
  48. package/lib/module/indexeddb-backend.js +21 -37
  49. package/lib/module/indexeddb-backend.js.map +1 -1
  50. package/lib/module/internal.js +5 -0
  51. package/lib/module/internal.js.map +1 -1
  52. package/lib/module/storage-core.js +62 -23
  53. package/lib/module/storage-core.js.map +1 -1
  54. package/lib/module/storage-runtime.js +1 -1
  55. package/lib/module/storage-runtime.js.map +1 -1
  56. package/lib/module/testing.js +59 -16
  57. package/lib/module/testing.js.map +1 -1
  58. package/lib/module/web-backend-contract.js +5 -2
  59. package/lib/module/web-backend-contract.js.map +1 -1
  60. package/lib/typescript/capabilities.d.ts.map +1 -1
  61. package/lib/typescript/index.d.ts +8 -1
  62. package/lib/typescript/index.d.ts.map +1 -1
  63. package/lib/typescript/index.web.d.ts +7 -1
  64. package/lib/typescript/index.web.d.ts.map +1 -1
  65. package/lib/typescript/indexeddb-backend.d.ts.map +1 -1
  66. package/lib/typescript/internal.d.ts +1 -0
  67. package/lib/typescript/internal.d.ts.map +1 -1
  68. package/lib/typescript/storage-core.d.ts +1 -1
  69. package/lib/typescript/storage-core.d.ts.map +1 -1
  70. package/lib/typescript/storage-runtime.d.ts +1 -1
  71. package/lib/typescript/storage-runtime.d.ts.map +1 -1
  72. package/lib/typescript/testing.d.ts +15 -2
  73. package/lib/typescript/testing.d.ts.map +1 -1
  74. package/lib/typescript/web-backend-contract.d.ts +1 -0
  75. package/lib/typescript/web-backend-contract.d.ts.map +1 -1
  76. package/package.json +14 -5
  77. package/react-native-nitro-storage.podspec +4 -2
  78. package/src/capabilities.ts +4 -9
  79. package/src/index.ts +19 -5
  80. package/src/index.web.ts +86 -19
  81. package/src/indexeddb-backend.ts +20 -42
  82. package/src/internal.ts +8 -0
  83. package/src/storage-core.ts +84 -33
  84. package/src/storage-runtime.ts +3 -1
  85. package/src/testing.ts +91 -18
  86. package/src/web-backend-contract.ts +6 -2
  87. package/testing/package.json +8 -0
@@ -44,12 +44,14 @@ void bindText(sqlite3_stmt* stmt, int index, const std::string& value) {
44
44
 
45
45
  std::string escapeLikePrefix(const std::string& prefix) {
46
46
  std::string escaped;
47
- escaped.reserve(prefix.size());
48
- for (const unsigned char character : prefix) {
47
+ escaped.reserve(prefix.size() + 1);
48
+ for (const char character : prefix) {
49
+ // SQLite LIKE stops at NUL; exact filtering below handles the full key.
50
+ if (character == '\0') break;
49
51
  if (character == '%' || character == '_' || character == '\\') {
50
52
  escaped.push_back('\\');
51
53
  }
52
- escaped.push_back(static_cast<char>(character));
54
+ escaped.push_back(character);
53
55
  }
54
56
  escaped.push_back('%');
55
57
  return escaped;
@@ -59,7 +61,12 @@ std::string escapeLikePrefix(const std::string& prefix) {
59
61
 
60
62
  SqliteDiskStore::SqliteDiskStore(std::string path) : path_(std::move(path)) {
61
63
  std::lock_guard<std::mutex> lock(mutex_);
62
- openLocked();
64
+ try {
65
+ openLocked();
66
+ } catch (...) {
67
+ closeLocked();
68
+ throw;
69
+ }
63
70
  }
64
71
 
65
72
  SqliteDiskStore::~SqliteDiskStore() {
@@ -119,6 +126,12 @@ void SqliteDiskStore::openLocked() {
119
126
  "value TEXT NOT NULL"
120
127
  ");"
121
128
  );
129
+ execLocked(
130
+ "CREATE TABLE IF NOT EXISTS meta ("
131
+ "k TEXT PRIMARY KEY NOT NULL,"
132
+ "v TEXT NOT NULL"
133
+ ");"
134
+ );
122
135
  setStmt_ = prepareLocked("INSERT OR REPLACE INTO kv(key, value) VALUES(?1, ?2);");
123
136
  getStmt_ = prepareLocked("SELECT value FROM kv WHERE key = ?1;");
124
137
  removeStmt_ = prepareLocked("DELETE FROM kv WHERE key = ?1;");
@@ -130,6 +143,8 @@ void SqliteDiskStore::openLocked() {
130
143
  insertAbsentStmt_ = prepareLocked(
131
144
  "INSERT OR IGNORE INTO kv(key, value) VALUES(?1, ?2);"
132
145
  );
146
+ getMetaStmt_ = prepareLocked("SELECT 1 FROM meta WHERE k = ?1 LIMIT 1;");
147
+ setMetaStmt_ = prepareLocked("INSERT OR REPLACE INTO meta(k, v) VALUES(?1, ?2);");
133
148
  }
134
149
 
135
150
  void SqliteDiskStore::closeLocked() {
@@ -148,6 +163,8 @@ void SqliteDiskStore::closeLocked() {
148
163
  finalize(sizeStmt_);
149
164
  finalize(clearStmt_);
150
165
  finalize(insertAbsentStmt_);
166
+ finalize(getMetaStmt_);
167
+ finalize(setMetaStmt_);
151
168
  if (db_ != nullptr) {
152
169
  sqlite3_close(db_);
153
170
  db_ = nullptr;
@@ -336,8 +353,7 @@ std::vector<std::string> SqliteDiskStore::getKeysByPrefix(const std::string& pre
336
353
  std::lock_guard<std::mutex> lock(mutex_);
337
354
  sqlite3_reset(prefixStmt_);
338
355
  sqlite3_clear_bindings(prefixStmt_);
339
- const std::string pattern = escapeLikePrefix(prefix);
340
- bindText(prefixStmt_, 1, pattern);
356
+ bindText(prefixStmt_, 1, escapeLikePrefix(prefix));
341
357
  std::vector<std::string> keys;
342
358
  while (true) {
343
359
  const int rc = sqlite3_step(prefixStmt_);
@@ -350,10 +366,13 @@ std::vector<std::string> SqliteDiskStore::getKeysByPrefix(const std::string& pre
350
366
  }
351
367
  const unsigned char* text = sqlite3_column_text(prefixStmt_, 0);
352
368
  const int bytes = sqlite3_column_bytes(prefixStmt_, 0);
353
- keys.emplace_back(
369
+ std::string key(
354
370
  text != nullptr ? reinterpret_cast<const char*>(text) : "",
355
371
  static_cast<size_t>(bytes)
356
372
  );
373
+ if (key.compare(0, prefix.size(), prefix) == 0) {
374
+ keys.push_back(std::move(key));
375
+ }
357
376
  }
358
377
  sqlite3_reset(prefixStmt_);
359
378
  return keys;
@@ -382,6 +401,49 @@ void SqliteDiskStore::clear() {
382
401
  }
383
402
  }
384
403
 
404
+ void SqliteDiskStore::insertAbsentLocked(
405
+ const std::vector<std::pair<std::string, std::string>>& entries
406
+ ) {
407
+ for (const auto& entry : entries) {
408
+ sqlite3_reset(insertAbsentStmt_);
409
+ sqlite3_clear_bindings(insertAbsentStmt_);
410
+ bindText(insertAbsentStmt_, 1, entry.first);
411
+ bindText(insertAbsentStmt_, 2, entry.second);
412
+ const int rc = sqlite3_step(insertAbsentStmt_);
413
+ sqlite3_reset(insertAbsentStmt_);
414
+ if (rc != SQLITE_DONE) {
415
+ throwSqlite(db_, "migrate", rc);
416
+ }
417
+ }
418
+ }
419
+
420
+ bool SqliteDiskStore::hasMigrationMarkerLocked(const std::string& name) {
421
+ sqlite3_reset(getMetaStmt_);
422
+ sqlite3_clear_bindings(getMetaStmt_);
423
+ bindText(getMetaStmt_, 1, name);
424
+ const int rc = sqlite3_step(getMetaStmt_);
425
+ sqlite3_reset(getMetaStmt_);
426
+ if (rc == SQLITE_ROW) {
427
+ return true;
428
+ }
429
+ if (rc == SQLITE_DONE) {
430
+ return false;
431
+ }
432
+ throwSqlite(db_, "hasMigrationMarker", rc);
433
+ }
434
+
435
+ void SqliteDiskStore::setMigrationMarkerLocked(const std::string& name) {
436
+ sqlite3_reset(setMetaStmt_);
437
+ sqlite3_clear_bindings(setMetaStmt_);
438
+ bindText(setMetaStmt_, 1, name);
439
+ bindText(setMetaStmt_, 2, "1");
440
+ const int rc = sqlite3_step(setMetaStmt_);
441
+ sqlite3_reset(setMetaStmt_);
442
+ if (rc != SQLITE_DONE) {
443
+ throwSqlite(db_, "setMigrationMarker", rc);
444
+ }
445
+ }
446
+
385
447
  void SqliteDiskStore::migrateIfAbsent(
386
448
  const std::vector<std::pair<std::string, std::string>>& entries
387
449
  ) {
@@ -391,17 +453,32 @@ void SqliteDiskStore::migrateIfAbsent(
391
453
  std::lock_guard<std::mutex> lock(mutex_);
392
454
  beginLocked();
393
455
  try {
394
- for (const auto& entry : entries) {
395
- sqlite3_reset(insertAbsentStmt_);
396
- sqlite3_clear_bindings(insertAbsentStmt_);
397
- bindText(insertAbsentStmt_, 1, entry.first);
398
- bindText(insertAbsentStmt_, 2, entry.second);
399
- const int rc = sqlite3_step(insertAbsentStmt_);
400
- sqlite3_reset(insertAbsentStmt_);
401
- if (rc != SQLITE_DONE) {
402
- throwSqlite(db_, "migrate", rc);
403
- }
456
+ insertAbsentLocked(entries);
457
+ commitLocked();
458
+ } catch (...) {
459
+ rollbackLocked();
460
+ throw;
461
+ }
462
+ }
463
+
464
+ bool SqliteDiskStore::hasMigrationMarker(const std::string& name) {
465
+ std::lock_guard<std::mutex> lock(mutex_);
466
+ return hasMigrationMarkerLocked(name);
467
+ }
468
+
469
+ void SqliteDiskStore::migrateOnce(
470
+ const std::string& name,
471
+ const std::vector<std::pair<std::string, std::string>>& entries
472
+ ) {
473
+ std::lock_guard<std::mutex> lock(mutex_);
474
+ beginLocked();
475
+ try {
476
+ if (hasMigrationMarkerLocked(name)) {
477
+ commitLocked();
478
+ return;
404
479
  }
480
+ insertAbsentLocked(entries);
481
+ setMigrationMarkerLocked(name);
405
482
  commitLocked();
406
483
  } catch (...) {
407
484
  rollbackLocked();
@@ -39,6 +39,11 @@ public:
39
39
  size_t size();
40
40
  void clear();
41
41
  void migrateIfAbsent(const std::vector<std::pair<std::string, std::string>>& entries);
42
+ bool hasMigrationMarker(const std::string& name);
43
+ void migrateOnce(
44
+ const std::string& name,
45
+ const std::vector<std::pair<std::string, std::string>>& entries
46
+ );
42
47
 
43
48
  private:
44
49
  void openLocked();
@@ -50,6 +55,9 @@ private:
50
55
  void setLocked(const std::string& key, const std::string& value);
51
56
  std::optional<std::string> getLocked(const std::string& key);
52
57
  void removeLocked(const std::string& key);
58
+ void insertAbsentLocked(const std::vector<std::pair<std::string, std::string>>& entries);
59
+ bool hasMigrationMarkerLocked(const std::string& name);
60
+ void setMigrationMarkerLocked(const std::string& name);
53
61
  sqlite3_stmt* prepareLocked(const char* sql);
54
62
 
55
63
  std::string path_;
@@ -63,6 +71,8 @@ private:
63
71
  sqlite3_stmt* sizeStmt_ = nullptr;
64
72
  sqlite3_stmt* clearStmt_ = nullptr;
65
73
  sqlite3_stmt* insertAbsentStmt_ = nullptr;
74
+ sqlite3_stmt* getMetaStmt_ = nullptr;
75
+ sqlite3_stmt* setMetaStmt_ = nullptr;
66
76
  std::mutex mutex_;
67
77
  };
68
78
 
@@ -14,28 +14,28 @@ const item = createStorageItem<T>({
14
14
 
15
15
  `StorageItemConfig<T>`:
16
16
 
17
- | Field | Type | Purpose |
18
- | ---------------------------- | -------------------------------- | ------------------------------------------------------------------- |
19
- | `key` | `string` | Storage key. Combined with `namespace` when provided. |
20
- | `scope` | `StorageScope` | Memory, Disk, or Secure. |
21
- | `defaultValue` | `T` | Value returned when no stored value exists. |
22
- | `serialize` | `(value: T) => string` | Custom string encoder. Defaults to primitive/JSON serialization. |
23
- | `deserialize` | `(value: string) => T` | Custom string decoder. |
24
- | `validate` | `(value: unknown) => value is T` | Runtime guard for stored data. |
25
- | `onValidationError` | `(invalidValue: unknown) => T` | Replacement value when validation fails. |
26
- | `expiration` | `{ ttlMs: number }` | Time-to-live for the value. |
27
- | `onExpired` | `(key: string) => void` | Called when a read detects TTL expiry. |
28
- | `readCache` | `boolean` | Reuse raw cache entries for reads, including cached missing values. |
29
- | `coalesceDiskWrites` | `boolean` | Buffer Disk writes until the next flush. |
30
- | `coalesceSecureWrites` | `boolean` | Buffer Secure writes until the next flush. |
31
- | `namespace` | `string` | Prefix keys as `namespace:key`. |
32
- | `biometric` | `boolean` | Store through biometric secure storage. |
33
- | `biometricLevel` | `BiometricLevel` | Require biometric/passcode or biometric-only access. |
34
- | `accessControl` | `AccessControl` | Platform secure accessibility setting. |
35
- | `group` | `string` | Register the item for group cleanup and inspection. |
36
- | `renameFrom` | `string \| readonly string[]` | Copy a legacy key on first read, then remove it. |
37
- | `fallbackToCacheOnReadError` | `boolean` | Return the last cached value when a backend read fails. |
38
- | `onReadError` | `(error: unknown) => void` | Observe a backend read failure before fallback or rethrow. |
17
+ | Field | Type | Purpose |
18
+ | ---------------------------- | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
19
+ | `key` | `string` | Storage key. Combined with `namespace` when provided. Must not be empty (`invalid_key`). |
20
+ | `scope` | `StorageScope` | Memory, Disk, or Secure. |
21
+ | `defaultValue` | `T` | Value returned when no stored value exists. |
22
+ | `serialize` | `(value: T) => string` | Custom string encoder. Defaults to primitive/JSON serialization. |
23
+ | `deserialize` | `(value: string) => T` | Custom string decoder. |
24
+ | `validate` | `(value: unknown) => value is T` | Runtime guard for stored data. |
25
+ | `onValidationError` | `(invalidValue: unknown) => T` | Replacement value when validation fails. |
26
+ | `expiration` | `{ ttlMs: number }` | Time-to-live for the value. |
27
+ | `onExpired` | `(key: string) => void` | Called when a read detects TTL expiry. |
28
+ | `readCache` | `boolean` | Reuse raw cache entries for reads, including cached missing values. Items without `readCache` or `fallbackToCacheOnReadError` do not fill the cache on read. |
29
+ | `coalesceDiskWrites` | `boolean` | Buffer Disk writes until the next flush. |
30
+ | `coalesceSecureWrites` | `boolean` | Buffer Secure writes until the next flush. |
31
+ | `namespace` | `string` | Prefix keys as `namespace:key`. |
32
+ | `biometric` | `boolean` | Store through biometric secure storage. |
33
+ | `biometricLevel` | `BiometricLevel` | Require biometric/passcode or biometric-only access. |
34
+ | `accessControl` | `AccessControl` | Platform secure accessibility setting. |
35
+ | `group` | `string` | Register the item for group cleanup and inspection. |
36
+ | `renameFrom` | `string \| readonly string[]` | Copy a legacy key on first read, then remove it. |
37
+ | `fallbackToCacheOnReadError` | `boolean` | Return the last cached value when a backend read fails with `keychain_locked`. Other errors still throw. Only iOS reports `keychain_locked`, so this has no effect on Android or web. |
38
+ | `onReadError` | `(error: unknown) => void` | Observe a backend read failure before fallback or rethrow. |
39
39
 
40
40
  `StorageItem<T>`:
41
41
 
@@ -65,6 +65,20 @@ const unsubscribe = profileItem.subscribeSelector(
65
65
  );
66
66
  ```
67
67
 
68
+ ## Scoped Item Factories
69
+
70
+ `memoryItem(config)`, `diskItem(config)`, and `secureItem(config)` take the same
71
+ config as `createStorageItem` without the `scope` field.
72
+
73
+ ```ts
74
+ const draft = memoryItem<string>({ key: "draft", defaultValue: "" });
75
+ const theme = diskItem<"light" | "dark">({
76
+ key: "theme",
77
+ defaultValue: "light",
78
+ });
79
+ const token = secureItem<string>({ key: "token", defaultValue: "" });
80
+ ```
81
+
68
82
  ## createSetItem
69
83
 
70
84
  ```ts
@@ -85,9 +99,11 @@ flags.getTyped();
85
99
  ## React Hooks
86
100
 
87
101
  ```ts
88
- const [value, setValue] = useStorage(item);
102
+ const [value, setValue, actions] = useStorage(item);
89
103
  const [selected, setItem] = useStorageSelector(item, selector, isEqual);
90
104
  const setOnly = useSetStorage(item);
105
+ const readOnly = useStorageValue(item);
106
+ const writeOnly = useStorageActions(item);
91
107
  ```
92
108
 
93
109
  See [react-hooks.md](react-hooks.md).
@@ -128,7 +144,8 @@ See [react-hooks.md](react-hooks.md).
128
144
  | `getMetricsSnapshot()` | Read aggregated metrics. |
129
145
  | `getScopedMetricsSnapshot()` | Read metrics grouped by storage scope. |
130
146
  | `resetMetrics()` | Clear metrics counters. |
131
- | `getCapabilities()` | Read runtime storage capabilities. |
147
+ | `getCacheMetrics()` | Read raw-cache hits, misses, live entries, and estimated bytes. |
148
+ | `getCapabilities()` | Read runtime storage capabilities. Native `backend.disk` is `"sqlite"`. |
132
149
  | `getSecurityCapabilities()` | Read secure backend capability metadata. |
133
150
  | `getSecureMetadata(key)` | Read secure metadata for one key without returning its value. |
134
151
  | `getAllSecureMetadata()` | Read secure metadata for all secure keys without values. |
@@ -259,6 +276,21 @@ the native or web adapter. `isStorageError(error, code)` matches one exact code
259
276
  without parsing platform message text. See [secure-storage.md](secure-storage.md)
260
277
  for recovery semantics.
261
278
 
279
+ | Code | Raised when |
280
+ | ----------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
281
+ | `keychain_locked` | The protected store is locked. |
282
+ | `authentication_required` | The item needs user authentication, or the user cancelled the prompt. |
283
+ | `key_invalidated` | The protecting key was invalidated, for example by a biometric enrolment change. |
284
+ | `biometric_unavailable` | The requested biometric level is not available on this device or OS version. |
285
+ | `storage_corruption` | Stored secure data could not be decrypted, or (Android) the Secure master key or store cannot be created. Do not retry. |
286
+ | `storage_compensation_failed` | A multi-step write failed and restoring the previous state also failed. |
287
+ | `unsupported` | The operation is not available on this platform or environment. |
288
+ | `invalid_key` | A storage key is empty. |
289
+
290
+ `StorageCompositeError` and `StorageCompensationError` describe errors that
291
+ carry a primary failure plus secondary failures from reconciliation or
292
+ compensation steps.
293
+
262
294
  `isKeychainLockedError(error)` is deprecated. It remains available for
263
295
  compatibility and returns `true` for `keychain_locked`,
264
296
  `authentication_required`, and `key_invalidated`.
@@ -273,9 +305,10 @@ getWebSecureStorageBackend();
273
305
  await flushWebStorageBackends();
274
306
  ```
275
307
 
276
- The web entry also exports `describeWebBackendCapabilities(backend)` and
277
- `isIndexedDBWebBackend(backend)`. The native entry keeps the web backend
278
- setters, getters, and flush function as typed no-ops for shared code.
308
+ Every entry, including native and `/testing`, exports
309
+ `describeWebBackendCapabilities(backend)` and `isIndexedDBWebBackend(backend)`.
310
+ The native and testing entries keep the web backend setters, getters, and flush
311
+ function as typed no-ops for shared code.
279
312
 
280
313
  See [web-backends.md](web-backends.md).
281
314
 
@@ -339,6 +372,18 @@ Common public types:
339
372
  - `PlatformStorage`
340
373
  - `PlatformScope`
341
374
  - `WebBackendCapabilities`
375
+ - `StorageCapabilities`
376
+ - `StorageCacheMetrics`
377
+ - `StorageExportOptions`
378
+ - `StorageEventObserverOptions`
379
+ - `StorageCompositeError`
380
+ - `StorageCompensationError`
381
+ - `SetStorageItem<T>`
382
+ - `SetItemConfig<T>`
383
+ - `StorageClearOptions`
384
+ - `StorageKeyRef`
385
+ - `StorageActions<T>`
386
+ - `StorageSetter<T>`
342
387
 
343
388
  `getCapabilities().writeBuffering` describes real per-mode durability:
344
389
 
@@ -350,4 +395,4 @@ Common public types:
350
395
 
351
396
  `describeWebBackendCapabilities(backend)` reports a backend's `buffered`, `flushable`, `closable`, and `subscribable` capabilities from the same typed contract used by the built-in backends.
352
397
 
353
- The IndexedDB subpath exports `createIndexedDBBackend()` and `IndexedDBBackendOptions`.
398
+ The IndexedDB subpath exports `createIndexedDBBackend()` and `IndexedDBBackendOptions`. The root export of `createIndexedDBBackend` is deprecated; import it from `react-native-nitro-storage/indexeddb-backend`.
@@ -28,19 +28,48 @@ Native Disk/Secure baselines require a device or simulator run and are not part
28
28
 
29
29
  ## Release Checklist
30
30
 
31
- Before publishing:
31
+ Before publishing, run the full release gate:
32
32
 
33
33
  ```sh
34
- bun run codegen:check
35
- bun run lint:check
36
- bun run format:check
37
- bun run typecheck
38
- bun run test:types
39
- bun run test
40
- bun run test:cpp
41
- bun run build
42
- bun run benchmark
43
- bun run --cwd packages/react-native-nitro-storage check:pack
34
+ bun run release:preflight
44
35
  ```
45
36
 
46
- Keep the dry-publish output in the release notes when validating a version locally.
37
+ It runs `check:ci` (including the benchmark), the example checks, the package
38
+ audit, and the publish dry run.
39
+
40
+ ## 2026-09-27 Memory enumeration experiment
41
+
42
+ Command: `bun run benchmark:enumeration`. The smoke form, `bun run benchmark:enumeration -- --smoke`, validates fixture execution only.
43
+
44
+ Host: Apple M4 Pro, darwin 27.0.0, Bun 1.4.2. This uses the actual web Memory implementation, not native SQLite or JSI. Each run contains five batches of 30 warm samples per operation and size. Every output key/value is checked outside the timed interval.
45
+
46
+ The comparison starts from the correctness-fixed implementation, not the original release, whose arbitrary-key behavior failed the fixture. It therefore does not establish an overall speedup over the published version.
47
+
48
+ The accepted iteration change removes the repeated Map lookup in `getAll` and the intermediate prefix-key array. Two separate process runs produced these median changes versus the corrected baseline (negative is faster):
49
+
50
+ | Keys | Operation | Run 1 | Run 2 |
51
+ | ------- | ------------- | ------ | ------ |
52
+ | 1,000 | `getAll` | -10.6% | -14.2% |
53
+ | 1,000 | `getByPrefix` | -8.4% | -3.7% |
54
+ | 10,000 | `getAll` | -10.4% | -7.4% |
55
+ | 10,000 | `getByPrefix` | -10.2% | -9.7% |
56
+ | 100,000 | `getAll` | -47.5% | -47.8% |
57
+ | 100,000 | `getByPrefix` | -12.2% | -6.9% |
58
+
59
+ Only the 100k-key `getAll` result clearly and repeatedly exceeds the proposed 10% target: median improved about 47%, with median batch p95 improving 43–48%. Smaller results vary and do not establish repeatable gains above the target.
60
+
61
+ Peak process RSS was 318/339 MB for the corrected baseline and 400/340 MB for the candidate (decimal MB). This high-water metric was noisy and triggers review; source review found no added retained collection or cache. It does not prove equal allocation cost. Native/device performance and retained-memory profiling remain separate acceptance evidence.
62
+
63
+ An earlier ordinary-property shortcut was reverted after a 10k-key regression. An entry-tuple iteration candidate was replaced after elevated peak RSS.
64
+
65
+ Fixture SHA-256: `1ec2feb7ff55d5c61f33e4f70948336ca97d0a7746a77d9c295d7ae4417463f6`. Lockfile SHA-256: `1a6a54f48038afa313fb8a8316611f2091edf8a133bcc16173836599f1831492`. Corrected core SHA-256: `f7e8687b69d93a0fec8318877e6227b8b42ee617b68a257a17e33f83f4492b17`. Candidate core SHA-256: `e8b68c5bb3305b07eeaaff609381ec28ab564424a8fdbb53a8014f6460b6c86e`.
66
+
67
+ Raw reports and environment: `/tmp/nitro-implementation.GbjgqhYj/storage-enumeration-v2-{corrected,candidate3}{,-repeat}.json` and `storage-enumeration-environment.json` on the execution host. These temporary receipts are not portable release artifacts; the table above preserves the selected results. No device or universal speed claim is made.
68
+
69
+ ### Original-release control and rejected prefix experiment
70
+
71
+ The additional `--ordinary` mode omits the special prototype key so the original 0.10.3 source and the corrected source can produce identical valid output. Two quiet process comparisons against the archived original revision measured 100k-key `getAll` median improvement of about 42%, with batch p95 improving 43–45%. This is a host web-Memory result only. The correctness-fixed `getByPrefix` was 17–20% slower at 100k keys and 15–23% slower at 1k; no overall prefix speedup is claimed.
72
+
73
+ A subsequent direct prefix scan preserved the observer path and passed independent source review, but was rejected: although 100k prefix medians improved 26–28% against the original, 1k medians regressed 66–67%. The final implementation retains the prior iteration change. The observer mutation/order regression test remains as coverage. Correctness and predictable small-workload behavior take priority over the large-workload result.
74
+
75
+ Original-control receipts: `storage-ordinary-{original,final}{,-repeat}.json` in the evidence directory above (`final` denotes retained candidate3). Rejected-experiment receipts: `storage-ordinary-candidate4{,-repeat}.json`. The ordinary-mode harness extends the fixture; its hash differs from the default-only fixture recorded above. Neither these comparisons nor peak process RSS establish native latency, retained-memory equivalence, or a universal improvement.
@@ -4,12 +4,14 @@ Use `migrateFromMMKV(mmkv, item, deleteAfterMigration?)` when an app already sto
4
4
 
5
5
  The helper reads in this order:
6
6
 
7
- 1. `mmkv.getString(key)`
7
+ 1. `mmkv.getString(key)`, then `JSON.parse` on the string. If parsing succeeds, the parsed value is written; otherwise the raw string is written.
8
8
  2. `mmkv.getNumber(key)`
9
9
  3. `mmkv.getBoolean(key)`
10
10
 
11
11
  It writes through `item.set()`, so custom serialization, validation, TTL behavior, and listeners remain active.
12
12
 
13
+ Because of the `JSON.parse` step, a string item can receive a non-string value: MMKV strings such as `"12345"`, `"true"`, or `"null"` are written as a number, a boolean, or `null`, while the item type still says `string`. For string items, add `validate` with an `onValidationError` fallback, or copy the raw value with `storage.setString(key, mmkv.getString(key), scope)` instead.
14
+
13
15
  ## Basic Migration
14
16
 
15
17
  ```ts
@@ -6,12 +6,18 @@ EncryptedSharedPreferences. Web Disk stays on the configured web backend
6
6
 
7
7
  ## Kept
8
8
 
9
- | Library | Where | Why |
10
- | ---------- | ------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
11
- | SQLite WAL | iOS `SqliteDiskStore`, Android `DiskSqliteStore` | Transactional batch writes, prefix queries without loading a plist/XML map, and crash-safe persistence. Closest Disk engine to a dedicated mmap KV without adding MMKV. |
9
+ | Library | Where | Why |
10
+ | ---------- | ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
11
+ | SQLite WAL | iOS `SqliteDiskStore`, Android `DiskSqliteStore` | Transactional batch writes, literal prefix queries in SQL, and crash-safe persistence. Closest Disk engine to a dedicated mmap KV without adding MMKV. |
12
12
 
13
13
  Existing UserDefaults suite keys and Android `NitroStorage` preferences are
14
- copied into SQLite on first open. Later Disk reads and writes use SQLite.
14
+ copied into SQLite once, on first open. A marker in the SQLite `meta` table
15
+ (`suite_v1` on iOS, `prefs_v1` on Android) records completion, so later launches
16
+ skip the import. On iOS the suite domain is kept for downgrade safety, and Disk
17
+ key enumeration still merges its keys. On Android the legacy `NitroStorage`
18
+ preferences are kept after the import for downgrade safety, like the iOS suite;
19
+ Disk deletes remove the key from them and `clear(Disk)` clears them, so a logout
20
+ wipe leaves no pre-SQLite copy behind. Later Disk reads and writes use SQLite.
15
21
 
16
22
  ## Evaluated and not shipped
17
23
 
@@ -47,7 +47,14 @@ export const recoveryCodeItem = createStorageItem<string>({
47
47
 
48
48
  `BiometricLevel.BiometryOnly` does not allow passcode fallback. Use `BiometricLevel.BiometryOrPasscode` when passcode fallback is acceptable.
49
49
 
50
- On Android 11 and newer, the two levels use separate Android Keystore keys with distinct allowed authenticators. Android 10 and older support `BiometryOrPasscode`; `BiometryOnly` reports `biometric_unavailable` because the older Keystore API cannot enforce that distinction safely for this storage backend. Android authorization remains valid for a 30-second window after successful authentication.
50
+ On Android 11 and newer, the two levels use separate Android Keystore keys with distinct allowed authenticators. Android 10 and older support `BiometryOrPasscode`; `BiometryOnly` reports `biometric_unavailable` because the older Keystore API cannot enforce that distinction safely for this storage backend.
51
+
52
+ ### Platform prompt behaviour
53
+
54
+ - **iOS:** reading a biometric item runs `SecItemCopyMatching` with user interaction allowed. The system shows the Face ID, Touch ID, or passcode sheet, and the synchronous JSI call blocks the JavaScript thread until the user answers. JavaScript timers, JavaScript-driven animations, and other JSI calls wait during that time. Read biometric items from a user action, not during render. Deleting a biometric item does not read it first unless an event listener or an unredacted event observer needs the previous value, so a delete does not show a prompt.
55
+ - **Android errors:** if the default Secure master key or store cannot be created (for example a Keystore key that exists but is unusable), Secure calls throw `storage_corruption`. This is not a temporary state: do not retry in a loop. Secure scope stays unavailable until the app data is cleared or the app is reinstalled; Memory and Disk keep working.
56
+ - **Android:** Nitro Storage never shows a prompt. Each biometric level is an `EncryptedSharedPreferences` file whose Tink keyset is wrapped by an Android Keystore key that requires user authentication within the last 30 seconds. That key is used only when the store is first opened in a process: `EncryptedSharedPreferences` decrypts the keyset once and keeps it in memory, and later reads and writes in the same process do not check authentication again. After the first successful open, biometric values stay readable without authentication until the process ends.
57
+ - **What Android apps must do:** run `androidx.biometric.BiometricPrompt` in the app before every read that must be gated, and treat the storage check as a one-time guard per process. Enable the config plugin option `addBiometricPermissions` so the app can declare `USE_BIOMETRIC` and `USE_FINGERPRINT` for its own prompt. Reading a `BiometryOrPasscode` item opens only the `BiometryOrPasscode` store, so a device-credential authentication is enough for that level.
51
58
 
52
59
  ## Access Control
53
60
 
@@ -61,6 +68,8 @@ On Android 11 and newer, the two levels use separate Android Keystore keys with
61
68
  | `AccessControl.WhenUnlockedThisDeviceOnly` | The secret should not migrate through backup/restore. |
62
69
  | `AccessControl.AfterFirstUnlockThisDeviceOnly` | Background refresh is needed, but migration is not allowed. |
63
70
 
71
+ On iOS the level applies on every write, including updates of an item that already exists, so changing `accessControl` or `storage.setAccessControl()` moves existing items to the new level on their next write. On iOS, `item.has()` and `storage.has(key, StorageScope.Secure)` throw `keychain_locked` while the keychain is locked, and a Keychain status error for any other unexpected status, instead of returning `false`. A biometric item that the Keychain reports as needing authentication counts as present, so `has()` on a biometric item returns `true` without a prompt; while the device is locked the same status also returns `true`. Listing, counting, and prefix queries on Secure keys throw a Keychain status error for unexpected statuses instead of returning an empty result.
72
+
64
73
  ## Secure Auth Item Map
65
74
 
66
75
  `createSecureAuthStorage()` creates a namespaced map of secure string items.
@@ -172,13 +181,16 @@ Recovery depends on the exact stable code:
172
181
 
173
182
  `isKeychainLockedError()` remains available for compatibility but is
174
183
  deprecated. It groups all three codes and must not be used to select retry
175
- behavior. The package does not block the synchronous JSI call, sleep, or retry
176
- internally; the application owns lifecycle scheduling and cancellation.
184
+ behavior. The package does not sleep or retry internally; the application owns
185
+ lifecycle scheduling and cancellation. Biometric reads on iOS block the
186
+ synchronous JSI call while the system prompt is visible.
177
187
 
178
- Do not enable `fallbackToCacheOnReadError` for access or refresh tokens unless
179
- the application explicitly accepts stale or revoked credentials. A cached
180
- value can hide the distinction between temporary unavailability and credential
181
- recovery.
188
+ `fallbackToCacheOnReadError` returns the last value read in this process only
189
+ when a read fails with `keychain_locked`. Every other error still throws. Only
190
+ iOS reports `keychain_locked`; Android and web never do, so the fallback has no
191
+ effect there. Do not
192
+ enable it for access or refresh tokens unless the application explicitly
193
+ accepts stale credentials while the device is locked.
182
194
 
183
195
  ## Android Secure Write Mode
184
196
 
@@ -204,13 +216,13 @@ entries, and surfaces native clear failures.
204
216
  ## iOS Legacy Disk Migration
205
217
 
206
218
  Older releases tracked observed Disk keys in `standardUserDefaults`. On iOS,
207
- adapter initialization copies a valid registry into the Nitro suite domain and
219
+ the first Disk operation copies a valid registry into the Nitro suite domain and
208
220
  removes each legacy source only after a target readback and synchronization
209
221
  check. `NSUserDefaults` is not transactional, so the migration stops on any
210
222
  failed synchronization or readback without deleting the source or registry.
211
223
  Malformed registries, same-domain or fallback stores, conflicting target
212
224
  values, and failed persistence therefore remain available for recovery. The
213
- migration is retryable on a later initialization; do not delete the registry
225
+ migration is retryable on a later launch; do not delete the registry
214
226
  manually while an upgrade is in progress.
215
227
 
216
228
  After that cutover, suite string keys are imported into the SQLite WAL Disk
@@ -237,10 +249,12 @@ See [web-backends.md](web-backends.md) for backend contracts and IndexedDB setup
237
249
  Before releasing secure-storage changes, run:
238
250
 
239
251
  ```sh
240
- bun run test -- --filter=react-native-nitro-storage
241
- bun run test:cpp -- --filter=react-native-nitro-storage
242
- (cd packages/react-native-nitro-storage && bun run check:pack)
252
+ bun run test
253
+ bun run test:cpp
254
+ bun run audit:package
243
255
  ```
244
256
 
257
+ `bun run release:preflight` runs these checks together with the full release gate.
258
+
245
259
  Also run the [physical-device Keychain lifecycle protocol](keychain-lifecycle-testing.md)
246
260
  when changing biometric, Keychain, or error-classification behavior.
@@ -4,6 +4,8 @@ Nitro Storage runs on web through synchronous backend contracts. Disk and Secure
4
4
 
5
5
  The default web backend is localStorage-style. Configure custom backends when you need IndexedDB persistence, tests with isolated storage, cross-tab sync, or a platform-specific secret wrapper.
6
6
 
7
+ Register a separate backend instance for each scope. If one instance is registered for both scopes, Disk enumeration skips the `__secure_` and `__bio_` keys that Secure scope writes, and each scope's `clear()` removes only its own keys, but separate stores keep the scopes fully isolated.
8
+
7
9
  ## Backend Contract
8
10
 
9
11
  ```ts
@@ -102,7 +104,7 @@ The IndexedDB backend exposes `close()` and rejects later synchronous operations
102
104
 
103
105
  ### Persistence Lifecycle
104
106
 
105
- IndexedDB transactions cannot block a page unload, so in-flight writes may be aborted when the page closes. The backend mitigates this by starting a flush on `pagehide` and on `visibilitychange` (hidden), and `flush()` awaits every pending transaction. When persistence fails, `flush()` throws an error that names the affected keys, and `onError` receives each individual failure.
107
+ IndexedDB transactions cannot block a page unload, so in-flight writes may be aborted when the page closes. The backend does not install `pagehide` or `visibilitychange` handlers, because there is no synchronous way to finish a pending transaction there. `flush()` awaits every pending transaction. When persistence fails, `flush()` throws an error that names the affected keys, and `onError` receives each individual failure.
106
108
 
107
109
  ```ts
108
110
  const backend = await createIndexedDBBackend("app-secure", "keyvalue", {
@@ -119,7 +121,9 @@ Treat IndexedDB persistence as best-effort under abrupt termination: keep a dura
119
121
 
120
122
  ## Cross-tab Updates
121
123
 
122
- The IndexedDB backend uses `BroadcastChannel` when available. Other tabs receive cache invalidation events and update their in-memory copy.
124
+ The IndexedDB backend uses `BroadcastChannel` when available. Other tabs receive cache invalidation events and update their in-memory copy. Messages that arrive while a new backend is still loading its snapshot are applied after the snapshot, so they are not overwritten.
125
+
126
+ The window `storage` event updates a scope only while that scope uses the default `localStorage` backend, and only for events from `localStorage`. Custom backends must sync through `subscribe(listener)`.
123
127
 
124
128
  If you provide your own backend, implement `subscribe(listener)` to keep Nitro Storage caches aligned with external writes.
125
129
 
@@ -0,0 +1,6 @@
1
+ {
2
+ "main": "../lib/commonjs/indexeddb-backend.js",
3
+ "module": "../lib/module/indexeddb-backend.js",
4
+ "types": "../lib/typescript/indexeddb-backend.d.ts",
5
+ "sideEffects": false
6
+ }
@@ -1,6 +1,7 @@
1
1
  #pragma once
2
2
 
3
3
  #include "../core/NativeStorageAdapter.hpp"
4
+ #include <atomic>
4
5
  #include <mutex>
5
6
  #include <unordered_set>
6
7
 
@@ -55,7 +56,10 @@ private:
55
56
  std::unordered_set<std::string> secureKeysCache_;
56
57
  std::unordered_set<std::string> biometricKeysCache_;
57
58
  bool secureKeyCacheHydrated_{false};
59
+ std::mutex diskMigrationMutex_;
60
+ std::atomic<bool> diskMigrated_{false};
58
61
 
62
+ void ensureDiskMigrated();
59
63
  void ensureSecureKeyCacheHydrated();
60
64
  void markSecureKeySet(const std::string& key);
61
65
  void markSecureKeyRemoved(const std::string& key);