react-native-nitro-storage 0.12.0 → 0.14.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 (61) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.md +107 -15
  3. package/SECURITY.md +2 -2
  4. package/android/build.gradle +5 -0
  5. package/android/src/main/cpp/AndroidStorageAdapterCpp.cpp +12 -13
  6. package/android/src/main/cpp/JniSize.hpp +21 -0
  7. package/android/src/main/java/com/nitrostorage/AndroidStorageAdapter.kt +94 -7
  8. package/android/src/main/java/com/nitrostorage/DiskSqliteStore.kt +147 -34
  9. package/cpp/bindings/HybridStorage.cpp +12 -4
  10. package/cpp/core/SqliteDiskStore.cpp +172 -26
  11. package/cpp/core/SqliteDiskStore.hpp +10 -1
  12. package/docs/api-reference.md +60 -54
  13. package/docs/batch-transactions-migrations.md +7 -0
  14. package/docs/native-libraries.md +28 -0
  15. package/docs/qa/agent-device-replay.md +70 -0
  16. package/docs/secure-storage.md +12 -6
  17. package/ios/IOSStorageAdapterCpp.mm +89 -22
  18. package/lib/commonjs/Storage.types.js +4 -2
  19. package/lib/commonjs/Storage.types.js.map +1 -1
  20. package/lib/commonjs/core/durability.js +30 -2
  21. package/lib/commonjs/core/durability.js.map +1 -1
  22. package/lib/commonjs/index.js.map +1 -1
  23. package/lib/commonjs/index.web.js.map +1 -1
  24. package/lib/commonjs/shared.js.map +1 -1
  25. package/lib/commonjs/storage-core.js +63 -9
  26. package/lib/commonjs/storage-core.js.map +1 -1
  27. package/lib/commonjs/testing.js +1 -0
  28. package/lib/commonjs/testing.js.map +1 -1
  29. package/lib/module/Storage.types.js +4 -2
  30. package/lib/module/Storage.types.js.map +1 -1
  31. package/lib/module/core/durability.js +30 -2
  32. package/lib/module/core/durability.js.map +1 -1
  33. package/lib/module/index.js.map +1 -1
  34. package/lib/module/index.web.js.map +1 -1
  35. package/lib/module/shared.js.map +1 -1
  36. package/lib/module/storage-core.js +63 -9
  37. package/lib/module/storage-core.js.map +1 -1
  38. package/lib/module/testing.js +1 -0
  39. package/lib/module/testing.js.map +1 -1
  40. package/lib/typescript/Storage.types.d.ts +4 -2
  41. package/lib/typescript/Storage.types.d.ts.map +1 -1
  42. package/lib/typescript/core/durability.d.ts +7 -1
  43. package/lib/typescript/core/durability.d.ts.map +1 -1
  44. package/lib/typescript/index.d.ts +4 -2
  45. package/lib/typescript/index.d.ts.map +1 -1
  46. package/lib/typescript/index.web.d.ts +4 -2
  47. package/lib/typescript/index.web.d.ts.map +1 -1
  48. package/lib/typescript/shared.d.ts +8 -1
  49. package/lib/typescript/shared.d.ts.map +1 -1
  50. package/lib/typescript/storage-core.d.ts +6 -3
  51. package/lib/typescript/storage-core.d.ts.map +1 -1
  52. package/lib/typescript/testing.d.ts +7 -4
  53. package/lib/typescript/testing.d.ts.map +1 -1
  54. package/package.json +2 -1
  55. package/src/Storage.types.ts +4 -2
  56. package/src/core/durability.ts +46 -2
  57. package/src/index.ts +4 -0
  58. package/src/index.web.ts +4 -0
  59. package/src/shared.ts +17 -1
  60. package/src/storage-core.ts +87 -13
  61. package/src/testing.ts +5 -0
@@ -20,7 +20,10 @@ public:
20
20
 
21
21
  static SqliteDiskStore& shared(const std::string& path);
22
22
  static void resetShared();
23
+ static void recreateShared(const std::string& path);
24
+ static void removeDatabaseFiles(const std::string& path);
23
25
  static std::string defaultPath();
26
+ static bool isValidUtf8(const std::string& value);
24
27
 
25
28
  const std::string& path() const { return path_; }
26
29
 
@@ -44,10 +47,14 @@ public:
44
47
  const std::string& name,
45
48
  const std::vector<std::pair<std::string, std::string>>& entries
46
49
  );
50
+ void recreate();
47
51
  void limitPageCountForTesting(int pages);
52
+ void limitValueLengthForTesting(int bytes);
48
53
 
49
54
  private:
50
55
  void openLocked();
56
+ void reopenLocked();
57
+ void ensureOpenLocked();
51
58
  void closeLocked();
52
59
  void execLocked(const char* sql);
53
60
  void beginLocked();
@@ -68,7 +75,9 @@ private:
68
75
  sqlite3_stmt* removeStmt_ = nullptr;
69
76
  sqlite3_stmt* hasStmt_ = nullptr;
70
77
  sqlite3_stmt* keysStmt_ = nullptr;
71
- sqlite3_stmt* prefixStmt_ = nullptr;
78
+ sqlite3_stmt* prefixRangeStmt_ = nullptr;
79
+ sqlite3_stmt* prefixFromStmt_ = nullptr;
80
+ sqlite3_stmt* prefixLikeStmt_ = nullptr;
72
81
  sqlite3_stmt* sizeStmt_ = nullptr;
73
82
  sqlite3_stmt* clearStmt_ = nullptr;
74
83
  sqlite3_stmt* insertAbsentStmt_ = nullptr;
@@ -112,49 +112,50 @@ 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
+ | `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()` | Drain queued Secure writes into the backend; not an Android `apply()` persistence barrier. |
142
+ | `setScheduledFlushErrorObserver(observer)` | Observe scheduled Disk/Secure flush failures; pass `undefined` to restore uncaught failures. Explicit flush calls still throw. |
143
+ | `setKeychainAccessGroup(group)` | Configure iOS Keychain access group. |
144
+ | `setMetricsObserver(observer)` | Receive operation timing events. |
145
+ | `getMetricsSnapshot()` | Read aggregated metrics. |
146
+ | `getScopedMetricsSnapshot()` | Read metrics grouped by storage scope. |
147
+ | `resetMetrics()` | Clear metrics counters. |
148
+ | `getCacheMetrics()` | Read raw-cache hits, misses, live entries, and estimated bytes. |
149
+ | `getCapabilities()` | Read runtime storage capabilities. Native `backend.disk` is `"sqlite"`. |
150
+ | `getSecurityCapabilities()` | Read secure backend capability metadata. |
151
+ | `getSecureMetadata(key)` | Read secure metadata for one key without returning its value. |
152
+ | `getAllSecureMetadata()` | Read secure metadata for all secure keys without values. |
153
+ | `getString(key, scope)` | Read a raw string. |
154
+ | `setString(key, value, scope)` | Write a raw string. |
155
+ | `deleteString(key, scope)` | Remove a raw key. |
156
+ | `export(scope, options?)` | Snapshot raw strings from one scope. Secure scope requires explicit unsafe opt-in. |
157
+ | `exportSecureUnsafe()` | Snapshot raw Secure strings for short-lived migration workflows. |
158
+ | `import(data, scope)` | Bulk import raw strings. |
158
159
 
159
160
  Raw string APIs bypass item serialization and validation. Prefer `StorageItem<T>` unless you are migrating, exporting/importing, or writing a custom integration.
160
161
 
@@ -234,6 +235,9 @@ runTransaction(StorageScope.Disk, (tx) => {
234
235
  ```
235
236
 
236
237
  If the callback throws, previously changed keys in that transaction are rolled back synchronously.
238
+ Promise or thenable results are rejected with `TypeError` and rollback. Context
239
+ methods are valid only during the synchronous callback. Finish awaited work
240
+ before entering the transaction.
237
241
 
238
242
  ## Migrations
239
243
 
@@ -249,6 +253,8 @@ migrateToLatest(StorageScope.Disk);
249
253
  ```
250
254
 
251
255
  Migration versions are tracked per scope.
256
+ Migration callbacks must be synchronous. An async callback is rejected without
257
+ advancing its version, and the next migration run retries that step.
252
258
 
253
259
  ## Secure Auth Storage
254
260
 
@@ -276,17 +282,17 @@ the native or web adapter. `isStorageError(error, code)` matches one exact code
276
282
  without parsing platform message text. See [secure-storage.md](secure-storage.md)
277
283
  for recovery semantics.
278
284
 
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, the Disk database is corrupt, 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
- | `storage_full` | The device or database is out of space, or the web storage quota is exceeded. Free space before retrying. |
289
- | `invalid_key` | A storage key is empty. |
285
+ | Code | Raised when |
286
+ | ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
287
+ | `keychain_locked` | The protected store is locked. |
288
+ | `authentication_required` | The item needs user authentication, or the user cancelled the prompt. |
289
+ | `key_invalidated` | The protecting key was invalidated, for example by a biometric enrolment change. |
290
+ | `biometric_unavailable` | The requested biometric level is not available on this device or OS version. |
291
+ | `storage_corruption` | Stored secure data could not be decoded, the Disk database is corrupt, or (Android) the Secure master key or store cannot be created. Do not retry. |
292
+ | `storage_compensation_failed` | A multi-step write failed and restoring the previous state also failed. |
293
+ | `unsupported` | The operation is not available on this platform or environment. |
294
+ | `storage_full` | The device or database is out of space, or the web storage quota is exceeded. Free space before retrying. |
295
+ | `invalid_key` | A storage key is empty. |
290
296
 
291
297
  `StorageCompositeError` and `StorageCompensationError` describe errors that
292
298
  carry a primary failure plus secondary failures from reconciliation or
@@ -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) => {
@@ -28,6 +28,34 @@ wipe leaves no pre-SQLite copy behind. Later Disk reads and writes use SQLite.
28
28
  | RocksDB / LevelDB | Rejected. Heavy native footprint for small preference-sized values. |
29
29
  | yyjson | Rejected for Disk. Typed TTL/JSON envelopes stay in JavaScript. |
30
30
 
31
+ ## Limits and durability
32
+
33
+ - Disk writes wait up to 5 seconds for another connection that holds the
34
+ SQLite write lock. The library uses one connection per process, so this wait
35
+ only happens when another process (for example an app extension in the same
36
+ container) is writing. Reads do not wait in WAL mode. Five seconds gives the
37
+ other writer time to finish and stays below the iOS and Android
38
+ unresponsive-app limits.
39
+ - If the Disk database file is deleted while the app runs, the open connection
40
+ keeps reading and writing the deleted file, and the next launch starts with
41
+ an empty database. The library does not check for this on each call, because
42
+ the check costs a system call on every write. Do not delete
43
+ `nitro-storage-disk.sqlite` yourself; call
44
+ `storage.clear(StorageScope.Disk)`.
45
+ - Each platform keeps its system SQLite checkpoint settings. iOS checkpoints
46
+ every 1000 pages and truncates the WAL to 32 KiB; these values are set in
47
+ code so they do not depend on the SQLite build. Android checkpoints every
48
+ 100 pages with a 512 KiB limit, applied by the Android framework to every
49
+ pooled connection.
50
+ - Android opens the Disk database in WAL mode. If that open fails for a reason
51
+ other than corruption (for example a full device while an older database
52
+ converts to WAL), it opens once without WAL and uses `synchronous=FULL`, so
53
+ reads keep working; the next launch tries WAL again. If WAL is active and the
54
+ device is so full that the WAL shared-memory file cannot be created, reads
55
+ fail with `storage_full` until space is free. iOS has the same limit.
56
+ - With WAL and `synchronous=NORMAL`, an app kill never loses a committed
57
+ write. A power loss can lose the most recent commits.
58
+
31
59
  ## Compatibility
32
60
 
33
61
  - Public Disk APIs (`get`/`set`/`setBatch`/`getKeysByPrefix`/`import`) are
@@ -0,0 +1,70 @@
1
+ # Storage agent-device replay
2
+
3
+ Run these flows only against an already installed release example that matches
4
+ the source revision under test. Select the platform and exact device every time:
5
+
6
+ ```sh
7
+ bun run example:replay --platform ios --udid <exact-udid>
8
+ bun run example:replay --platform android --serial <exact-serial>
9
+ ```
10
+
11
+ To run selected manifest suites, pass `--flow <id>`. The runner accepts this
12
+ option more than once for distinct suite IDs:
13
+
14
+ ```sh
15
+ bun run example:replay --platform ios --udid <exact-udid> --flow integrity
16
+ bun run example:replay --platform android --serial <exact-serial> --flow keychain --flow persistence-relaunch
17
+ ```
18
+
19
+ Use an installed release build from the source being checked. The replay does
20
+ not start Metro, prebuild, or build an app. A development client, a different
21
+ source revision, an inferred device, or a run without the exact platform and
22
+ device selector is not release replay evidence. The task owner must separately
23
+ authorize device work after the required package implementations are ready.
24
+
25
+ Before replay, check that the example source and replay manifest match the
26
+ committed source lock. If a flow, status ID, or expected value changes, update
27
+ `e2e/storage-replay-coverage.json` and refresh that lock with the repository's
28
+ replay maintenance command. Run the checker again before the device command:
29
+
30
+ ```sh
31
+ bun run example:replay:refresh
32
+ bun run example:replay:check
33
+ bun run example:replay:test
34
+ ```
35
+
36
+ The runner supplies a fresh UUID as `RUN_ID` to each replay. The persistence
37
+ flow writes one `RUN_ID`-scoped Disk key, closes and relaunches the installed
38
+ app with the same ID, compares the stored value with the expected value, and
39
+ deletes that key. Other smoke and lab cases use reserved QA keys and clean only
40
+ those keys. The default replay never clears a whole storage scope or the app's
41
+ data.
42
+
43
+ Each run uses a unique session and an artifact directory under the OS temporary
44
+ directory. `agent-device test` closes each attempt session itself, including
45
+ on failure.
46
+
47
+ A replay passes only when each suite reaches its finished status and every
48
+ required status ID contains its expected public API value. A `fail=0` summary by
49
+ itself is not sufficient. Smoke and integrity rows are asserted through the
50
+ on-screen `smoke-results` and `e2e-integrity-results` labels because
51
+ agent-device `.ad` waits only see on-screen elements. The keychain suite runs one no-prompt Secure
52
+ roundtrip and keeps biometric, lock, corruption, and hardware-backed checks
53
+ explicitly pending. The smoke test's constructed `storage_full` error is read
54
+ through the public `/testing` entrypoint; it proves error-code classification
55
+ at that test adapter boundary only.
56
+
57
+ The native smoke flow requires 39 passing cases out of 41, zero failures, and
58
+ the two expected web-only skips. Update that expected summary when cases change;
59
+ do not accept a new native skip as coverage. The integrity flow also checks that
60
+ a Promise-returning transaction rolls back and closes its context on Memory,
61
+ Disk, and Secure.
62
+
63
+ Native replay rows identify the public runtime platform and backend capability,
64
+ then assert values returned through the default package entrypoint. This
65
+ supports installed-app Disk and Secure behavior claims for that target. It does
66
+ not prove hardware-backed keys, biometrics, locked-device behavior, corruption
67
+ recovery, or a native Disk/Secure write failure. Those checks need controlled
68
+ native or hardware prerequisites and remain pending in the coverage manifest.
69
+ Unit tests for an injected adapter boundary are useful logic evidence, but they
70
+ do not close a native-runtime prerequisite.
@@ -206,12 +206,18 @@ refreshTokenItem.set("opaque-refresh-token");
206
206
  ```
207
207
 
208
208
  Coalesced secure item writes remain in a last-write-wins queue until the next
209
- microtask or an explicit `flushSecureWrites()`. A failed flush throws and keeps
210
- failed and unattempted writes queued for a later retry. Call
211
- `flushSecureWrites()` before assertions, namespace clears, or any boundary that
212
- requires deterministic persistence. `storage.clearBiometric()` is also a
213
- durability barrier: it flushes pending Secure writes before clearing biometric
214
- entries, and surfaces native clear failures.
209
+ microtask or an explicit `flushSecureWrites()`. An explicit flush failure throws
210
+ and keeps failed and unattempted writes queued for retry. Scheduled failures
211
+ propagate unless `storage.setScheduledFlushErrorObserver()` is installed; it
212
+ receives `{ scope, error }` while the failed writes remain queued.
213
+
214
+ `flushSecureWrites()` drains the JavaScript queue into the backend. It does not
215
+ wait for Android `apply()` persistence. Use the default synchronous mode or
216
+ select `storage.setSecureWritesAsync(false)` before writes that need synchronous
217
+ native persistence. Changing the mode is not a barrier for earlier `apply()`
218
+ calls. `storage.clearBiometric()` drains pending Secure writes before clearing
219
+ biometric entries and surfaces native clear failures; it also cannot wait for
220
+ earlier Android `apply()` calls.
215
221
 
216
222
  ## iOS Legacy Disk Migration
217
223
 
@@ -363,9 +363,9 @@ std::string diskStorePathForTesting() {
363
363
  // --- Disk ---
364
364
 
365
365
  void IOSStorageAdapterCpp::setDisk(const std::string& key, const std::string& value) {
366
+ NSString* nsKey = nsStringFromStdString(key);
366
367
  ensureDiskMigrated();
367
368
  NitroSqliteDiskStore().set(key, value);
368
- NSString* nsKey = nsStringFromStdString(key);
369
369
  NSUserDefaults* defaults = NitroDiskDefaults();
370
370
  NSUserDefaults* standard = [NSUserDefaults standardUserDefaults];
371
371
  if (defaults != standard && [standard objectForKey:nsKey] != nil) {
@@ -383,14 +383,17 @@ std::optional<std::string> IOSStorageAdapterCpp::getDisk(const std::string& key)
383
383
  NSString* result = migrateLegacyDiskValue(nsKey);
384
384
  if (!result) return std::nullopt;
385
385
  const std::string value = stdStringFromNSString(result);
386
- NitroSqliteDiskStore().set(key, value);
386
+ try {
387
+ NitroSqliteDiskStore().set(key, value);
388
+ } catch (const std::exception&) {
389
+ }
387
390
  return value;
388
391
  }
389
392
 
390
393
  void IOSStorageAdapterCpp::deleteDisk(const std::string& key) {
394
+ NSString* nsKey = nsStringFromStdString(key);
391
395
  ensureDiskMigrated();
392
396
  NitroSqliteDiskStore().remove(key);
393
- NSString* nsKey = nsStringFromStdString(key);
394
397
  NSUserDefaults* defaults = NitroDiskDefaults();
395
398
  [defaults removeObjectForKey:nsKey];
396
399
  NSUserDefaults* standard = [NSUserDefaults standardUserDefaults];
@@ -445,13 +448,33 @@ std::vector<std::string> IOSStorageAdapterCpp::getAllKeysDisk() {
445
448
  return keys;
446
449
  }
447
450
 
451
+ static std::vector<std::string> legacyDefaultsDiskKeys() {
452
+ std::vector<std::string> keys;
453
+ NSUserDefaults* defaults = NitroDiskDefaults();
454
+ NSDictionary<NSString*, id>* entries = [defaults persistentDomainForName:kDiskSuiteName] ?: @{};
455
+ NSUserDefaults* standard = [NSUserDefaults standardUserDefaults];
456
+ for (NSString* key in entries) {
457
+ if (!isInternalDiskKey(key)) {
458
+ keys.push_back(stdStringFromNSString(key));
459
+ }
460
+ }
461
+ for (NSString* key in [registeredLegacyDiskKeys() allObjects]) {
462
+ if ([entries objectForKey:key] == nil &&
463
+ [standard stringForKey:key] != nil) {
464
+ keys.push_back(stdStringFromNSString(key));
465
+ }
466
+ }
467
+ return keys;
468
+ }
469
+
448
470
  std::vector<std::string> IOSStorageAdapterCpp::getKeysByPrefixDisk(const std::string& prefix) {
449
471
  ensureDiskMigrated();
450
472
  std::unordered_set<std::string> combined;
451
473
  for (const auto& key : NitroSqliteDiskStore().getKeysByPrefix(prefix)) {
452
474
  combined.insert(key);
453
475
  }
454
- for (const auto& key : getAllKeysDisk()) {
476
+ const auto remaining = SqliteDiskStore::isValidUtf8(prefix) ? legacyDefaultsDiskKeys() : getAllKeysDisk();
477
+ for (const auto& key : remaining) {
455
478
  if (key.rfind(prefix, 0) == 0) {
456
479
  combined.insert(key);
457
480
  }
@@ -466,20 +489,31 @@ std::vector<std::string> IOSStorageAdapterCpp::getKeysByPrefixDisk(const std::st
466
489
 
467
490
  size_t IOSStorageAdapterCpp::sizeDisk() {
468
491
  ensureDiskMigrated();
469
- return getAllKeysDisk().size();
492
+ auto& store = NitroSqliteDiskStore();
493
+ size_t count = store.size();
494
+ std::unordered_set<std::string> counted;
495
+ for (const auto& key : legacyDefaultsDiskKeys()) {
496
+ if (counted.insert(key).second && !store.has(key)) {
497
+ count += 1;
498
+ }
499
+ }
500
+ return count;
470
501
  }
471
502
 
472
503
  void IOSStorageAdapterCpp::setDiskBatch(
473
504
  const std::vector<std::string>& keys,
474
505
  const std::vector<std::string>& values
475
506
  ) {
507
+ NSMutableArray<NSString*>* nsKeys = [NSMutableArray arrayWithCapacity:keys.size()];
508
+ for (size_t i = 0; i < keys.size() && i < values.size(); ++i) {
509
+ [nsKeys addObject:nsStringFromStdString(keys[i])];
510
+ }
476
511
  ensureDiskMigrated();
477
512
  NitroSqliteDiskStore().setBatch(keys, values);
478
513
  NSUserDefaults* defaults = NitroDiskDefaults();
479
514
  NSUserDefaults* standard = [NSUserDefaults standardUserDefaults];
480
515
  NSMutableArray* legacyKeysToRemove = [NSMutableArray array];
481
- for (size_t i = 0; i < keys.size() && i < values.size(); ++i) {
482
- NSString* nsKey = nsStringFromStdString(keys[i]);
516
+ for (NSString* nsKey in nsKeys) {
483
517
  if (defaults != standard && [standard objectForKey:nsKey] != nil) {
484
518
  [legacyKeysToRemove addObject:nsKey];
485
519
  }
@@ -503,10 +537,13 @@ std::vector<std::optional<std::string>> IOSStorageAdapterCpp::getDiskBatch(
503
537
  }
504
538
 
505
539
  void IOSStorageAdapterCpp::deleteDiskBatch(const std::vector<std::string>& keys) {
540
+ NSMutableArray<NSString*>* nsKeys = [NSMutableArray arrayWithCapacity:keys.size()];
541
+ for (const auto& key : keys) {
542
+ [nsKeys addObject:nsStringFromStdString(key)];
543
+ }
506
544
  ensureDiskMigrated();
507
545
  NitroSqliteDiskStore().removeBatch(keys);
508
- for (const auto& key : keys) {
509
- NSString* nsKey = nsStringFromStdString(key);
546
+ for (NSString* nsKey in nsKeys) {
510
547
  NSUserDefaults* defaults = NitroDiskDefaults();
511
548
  [defaults removeObjectForKey:nsKey];
512
549
  NSUserDefaults* standard = [NSUserDefaults standardUserDefaults];
@@ -517,9 +554,13 @@ void IOSStorageAdapterCpp::deleteDiskBatch(const std::vector<std::string>& keys)
517
554
  }
518
555
  }
519
556
 
520
- void IOSStorageAdapterCpp::clearDisk() {
521
- ensureDiskMigrated();
522
- NitroSqliteDiskStore().clear();
557
+ static bool isRecoverableByDiskClear(const std::exception& error) {
558
+ const std::string message = error.what();
559
+ return message.rfind("[nitro-error:storage_corruption] NitroStorage: Disk SQLite ", 0) == 0 ||
560
+ message.rfind("[nitro-error:storage_full] NitroStorage: Disk SQLite ", 0) == 0;
561
+ }
562
+
563
+ static void clearDiskDefaults() {
523
564
  NSUserDefaults* defaults = NitroDiskDefaults();
524
565
  NSDictionary<NSString*, id>* entries = [defaults persistentDomainForName:kDiskSuiteName] ?: @{};
525
566
  NSMutableSet* legacyKeys = [registeredLegacyDiskKeys() mutableCopy];
@@ -545,6 +586,25 @@ void IOSStorageAdapterCpp::clearDisk() {
545
586
  [defaults removeObjectForKey:kLegacyDiskKeysRegistryKey];
546
587
  }
547
588
 
589
+ void IOSStorageAdapterCpp::clearDisk() {
590
+ bool recreate = false;
591
+ try {
592
+ ensureDiskMigrated();
593
+ NitroSqliteDiskStore().clear();
594
+ } catch (const std::exception& error) {
595
+ if (!isRecoverableByDiskClear(error)) {
596
+ throw;
597
+ }
598
+ recreate = true;
599
+ }
600
+ clearDiskDefaults();
601
+ if (recreate) {
602
+ SqliteDiskStore::recreateShared(NitroDiskStorePath());
603
+ std::lock_guard<std::mutex> lock(diskMigrationMutex_);
604
+ diskMigrated_.store(false, std::memory_order_release);
605
+ }
606
+ }
607
+
548
608
  // --- Secure (Keychain) ---
549
609
 
550
610
  static NSMutableDictionary* baseKeychainQuery(NSString* key, NSString* service, NSString* accessGroup) {
@@ -695,6 +755,18 @@ static void setSecureValue(
695
755
  throw keychainStatusError(status, "Secure set");
696
756
  }
697
757
 
758
+ static std::string decodeKeychainText(CFTypeRef result, const std::string& operation) {
759
+ NSData* data = (__bridge_transfer NSData*)result;
760
+ NSString* text = data ? [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding] : nil;
761
+ if (!text) {
762
+ throw taggedStorageError(
763
+ "storage_corruption",
764
+ "NitroStorage: " + operation + " failed: the Keychain item has no data or is not valid UTF-8 text."
765
+ );
766
+ }
767
+ return stdStringFromNSString(text);
768
+ }
769
+
698
770
  static std::optional<std::string> getSecureValue(NSString* nsKey, NSString* group) {
699
771
  NSMutableDictionary* query = baseKeychainQuery(nsKey, kKeychainService, group);
700
772
  query[(__bridge id)kSecReturnData] = @YES;
@@ -703,10 +775,8 @@ static std::optional<std::string> getSecureValue(NSString* nsKey, NSString* grou
703
775
 
704
776
  CFTypeRef result = NULL;
705
777
  OSStatus status = SecItemCopyMatching((__bridge CFDictionaryRef)query, &result);
706
- if (status == errSecSuccess && result) {
707
- NSData* data = (__bridge_transfer NSData*)result;
708
- NSString* str = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding];
709
- if (str) return stdStringFromNSString(str);
778
+ if (status == errSecSuccess) {
779
+ return decodeKeychainText(result, "Secure get");
710
780
  }
711
781
  if (status == errSecInteractionNotAllowed) {
712
782
  throw taggedStorageError(
@@ -1169,10 +1239,8 @@ std::optional<std::string> IOSStorageAdapterCpp::getSecureBiometric(const std::s
1169
1239
 
1170
1240
  CFTypeRef result = NULL;
1171
1241
  OSStatus status = SecItemCopyMatching((__bridge CFDictionaryRef)query, &result);
1172
- if (status == errSecSuccess && result) {
1173
- NSData* data = (__bridge_transfer NSData*)result;
1174
- NSString* str = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding];
1175
- if (str) return stdStringFromNSString(str);
1242
+ if (status == errSecSuccess) {
1243
+ return decodeKeychainText(result, "Biometric get");
1176
1244
  }
1177
1245
  if (status == errSecInteractionNotAllowed) {
1178
1246
  throw taggedStorageError(
@@ -1251,8 +1319,7 @@ void IOSStorageAdapterCpp::clearSecureBiometric() {
1251
1319
  "NitroStorage: Cannot clear biometric storage: keychain is locked (errSecInteractionNotAllowed)"
1252
1320
  );
1253
1321
  }
1254
- throw std::runtime_error(
1255
- std::string("NitroStorage: clearSecureBiometric failed with status ") + std::to_string(status));
1322
+ throw keychainStatusError(status, "clearSecureBiometric");
1256
1323
  }
1257
1324
  {
1258
1325
  std::lock_guard<std::mutex> lock(secureKeysMutex_);
@@ -26,9 +26,11 @@ let AccessControl = exports.AccessControl = /*#__PURE__*/function (AccessControl
26
26
  let BiometricLevel = exports.BiometricLevel = /*#__PURE__*/function (BiometricLevel) {
27
27
  /** No biometric requirement (default). */
28
28
  BiometricLevel[BiometricLevel["None"] = 0] = "None";
29
- /** Require biometric or passcode for each access. */
29
+ /** iOS: Keychain user presence. Android: recent authentication when opening
30
+ * the protected store; later access uses its cached keyset. Web: no biometric enforcement. */
30
31
  BiometricLevel[BiometricLevel["BiometryOrPasscode"] = 1] = "BiometryOrPasscode";
31
- /** Require biometric only (no passcode fallback). */
32
+ /** Biometric-only Keychain/Keystore policy, without passcode fallback.
33
+ * Android authenticates when opening the store, not each access. Web: no biometric enforcement. */
32
34
  BiometricLevel[BiometricLevel["BiometryOnly"] = 2] = "BiometryOnly";
33
35
  return BiometricLevel;
34
36
  }({});
@@ -1 +1 @@
1
- {"version":3,"names":["StorageScope","exports","AccessControl","BiometricLevel"],"sourceRoot":"../../src","sources":["Storage.types.ts"],"mappings":";;;;;;IAAYA,YAAY,GAAAC,OAAA,CAAAD,YAAA,0BAAZA,YAAY;EAAZA,YAAY,CAAZA,YAAY;EAAZA,YAAY,CAAZA,YAAY;EAAZA,YAAY,CAAZA,YAAY;EAAA,OAAZA,YAAY;AAAA;AAAA,IAMZE,aAAa,GAAAD,OAAA,CAAAC,aAAA,0BAAbA,aAAa;EACvB;EADUA,aAAa,CAAbA,aAAa;EAGvB;EAHUA,aAAa,CAAbA,aAAa;EAKvB;EALUA,aAAa,CAAbA,aAAa;EAOvB;EAPUA,aAAa,CAAbA,aAAa;EASvB;EATUA,aAAa,CAAbA,aAAa;EAAA,OAAbA,aAAa;AAAA;AAAA,IAabC,cAAc,GAAAF,OAAA,CAAAE,cAAA,0BAAdA,cAAc;EACxB;EADUA,cAAc,CAAdA,cAAc;EAGxB;EAHUA,cAAc,CAAdA,cAAc;EAKxB;EALUA,cAAc,CAAdA,cAAc;EAAA,OAAdA,cAAc;AAAA","ignoreList":[]}
1
+ {"version":3,"names":["StorageScope","exports","AccessControl","BiometricLevel"],"sourceRoot":"../../src","sources":["Storage.types.ts"],"mappings":";;;;;;IAAYA,YAAY,GAAAC,OAAA,CAAAD,YAAA,0BAAZA,YAAY;EAAZA,YAAY,CAAZA,YAAY;EAAZA,YAAY,CAAZA,YAAY;EAAZA,YAAY,CAAZA,YAAY;EAAA,OAAZA,YAAY;AAAA;AAAA,IAMZE,aAAa,GAAAD,OAAA,CAAAC,aAAA,0BAAbA,aAAa;EACvB;EADUA,aAAa,CAAbA,aAAa;EAGvB;EAHUA,aAAa,CAAbA,aAAa;EAKvB;EALUA,aAAa,CAAbA,aAAa;EAOvB;EAPUA,aAAa,CAAbA,aAAa;EASvB;EATUA,aAAa,CAAbA,aAAa;EAAA,OAAbA,aAAa;AAAA;AAAA,IAabC,cAAc,GAAAF,OAAA,CAAAE,cAAA,0BAAdA,cAAc;EACxB;EADUA,cAAc,CAAdA,cAAc;EAGxB;AACF;EAJYA,cAAc,CAAdA,cAAc;EAMxB;AACF;EAPYA,cAAc,CAAdA,cAAc;EAAA,OAAdA,cAAc;AAAA","ignoreList":[]}