react-native-nitro-storage 0.10.3 → 0.10.4

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 CHANGED
@@ -6,6 +6,20 @@ 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.10.4] - 2026-09-27
10
+
11
+ ### Breaking changes
12
+
13
+ - None.
14
+
15
+ ### Fixed
16
+
17
+ - Disk prefix queries and namespace removal now distinguish ASCII case, so clearing `user` does not remove `User` keys.
18
+ - Android Disk and Secure storage preserve embedded NUL characters in keys and values across scalar and batch operations, preventing truncated-key collisions.
19
+ - iOS Secure storage preserves embedded NUL keys and values, and legacy Disk migration no longer aliases a shorter host-app defaults key when a key contains NUL.
20
+ - Memory prefix reads return the original raw string, including values beginning with the reserved internal prefix.
21
+ - Raw enumeration, export, and set items preserve arbitrary keys such as `__proto__`, `constructor`, and `toString` as own properties.
22
+
9
23
  ## [0.10.3] - 2026-09-18
10
24
 
11
25
  ### Breaking changes
package/README.md CHANGED
@@ -35,6 +35,7 @@ pagination, conflict resolution, or remote synchronization.
35
35
  - [Legacy Key Migration And Secure Resilience](#legacy-key-migration-and-secure-resilience)
36
36
  - [React Hooks](#react-hooks)
37
37
  - [Storage Scopes](#storage-scopes)
38
+ - [Prefix Queries](#prefix-queries)
38
39
  - [Secure Storage](#secure-storage)
39
40
  - [Batch Operations](#batch-operations)
40
41
  - [Events And Observability](#events-and-observability)
@@ -68,7 +69,7 @@ Nitro peer requirement: `react-native-nitro-modules >=0.37.0 <0.38.0`.
68
69
  The package gate uses React Native `0.86.3` and the Strict TypeScript API.
69
70
  `check:ci` also compiles the public source against React Native `0.87.0`'s
70
71
  Strict TypeScript API; this does not change the runtime baseline. The Expo
71
- example uses Expo SDK `57.0.24`, React Native
72
+ example uses Expo SDK `57.0.25`, React Native
72
73
  `0.86.3`, React `19.2.3`, and Nitro Modules `0.37.1`, which is the React Native
73
74
  version supported by that Expo SDK. Do not override Expo's React Native version.
74
75
 
@@ -77,7 +78,7 @@ before installing this package, then rebuild the native app so the generated
77
78
  Nitro bindings and native runtime use the same major-minor version:
78
79
 
79
80
  ```sh
80
- bun add react-native-nitro-modules@0.37.1 react-native-nitro-storage@0.10.3
81
+ bun add react-native-nitro-modules@0.37.1 react-native-nitro-storage@0.10.4
81
82
  bunx expo prebuild
82
83
  ```
83
84
 
@@ -393,6 +394,26 @@ const tokenActions = useStorageActions(tokenItem); // { set, merge, reset, remov
393
394
  | `StorageScope.Disk` | SQLite WAL on iOS/Android (imports UserDefaults / SharedPreferences once); configured web backend | Preferences, feature flags, onboarding state, and non-secret persisted data. |
394
395
  | `StorageScope.Secure` | Keychain on iOS, Android Keystore-backed preferences | Refresh tokens, credentials, API tokens, and biometric-protected values. |
395
396
 
397
+ ## Prefix Queries
398
+
399
+ Prefix queries use literal, case-sensitive matching on every backend. `User::`
400
+ and `user::` are separate namespaces; `%`, `_`, and `\\` are literal characters.
401
+ Raw enumeration returns the original strings and preserves arbitrary keys as own
402
+ properties on an ordinary object, including `__proto__`.
403
+
404
+ Disk and Secure strings preserve embedded NUL characters in keys and values,
405
+ including batch operations. Earlier versions could truncate strings at Android
406
+ JNI or iOS Foundation boundaries; this fix cannot reconstruct previously lost
407
+ suffixes. iOS legacy Disk migration also preserves full keys without aliasing
408
+ shorter host-app defaults keys.
409
+
410
+ ```ts
411
+ storage.setString("User::theme", "dark", StorageScope.Disk);
412
+ storage.setString("user::theme", "light", StorageScope.Disk);
413
+ const settings = storage.getByPrefix("User::", StorageScope.Disk);
414
+ // settings["User::theme"] === "dark"; no lowercase namespace entries.
415
+ ```
416
+
396
417
  ## Secure Storage
397
418
 
398
419
  ```ts
@@ -1,5 +1,8 @@
1
1
  #include "AndroidStorageAdapterCpp.hpp"
2
2
 
3
+ #include <limits>
4
+ #include <stdexcept>
5
+
3
6
  namespace NitroStorage {
4
7
 
5
8
  using namespace facebook::jni;
@@ -7,10 +10,25 @@ using JavaStringArray = JArrayClass<jstring>;
7
10
 
8
11
  namespace {
9
12
 
13
+ local_ref<JString> toJavaString(const std::string& value) {
14
+ // fbjni's std::string overload uses c_str(), which truncates embedded NUL.
15
+ if (value.find('\0') == std::string::npos) return make_jstring(value);
16
+ if (value.size() > static_cast<size_t>(std::numeric_limits<jsize>::max())) {
17
+ throw std::length_error("Storage string exceeds the Java array limit");
18
+ }
19
+ const auto size = static_cast<jsize>(value.size());
20
+ auto bytes = JArrayByte::newArray(size);
21
+ bytes->setRegion(0, size, reinterpret_cast<const jbyte*>(value.data()));
22
+ static auto constructor = JString::javaClassStatic()->getConstructor<
23
+ jstring(jbyteArray, jstring)>();
24
+ auto charset = make_jstring("UTF-8");
25
+ return JString::javaClassStatic()->newObject(constructor, bytes.get(), charset.get());
26
+ }
27
+
10
28
  local_ref<JavaStringArray> toJavaStringArray(const std::vector<std::string>& values) {
11
29
  auto javaArray = JavaStringArray::newArray(static_cast<jsize>(values.size()));
12
30
  for (size_t i = 0; i < values.size(); ++i) {
13
- auto javaValue = make_jstring(values[i]);
31
+ auto javaValue = toJavaString(values[i]);
14
32
  javaArray->setElement(static_cast<jsize>(i), javaValue.get());
15
33
  }
16
34
  return javaArray;
@@ -61,25 +79,25 @@ AndroidStorageAdapterCpp::~AndroidStorageAdapterCpp() = default;
61
79
  // --- Disk ---
62
80
 
63
81
  void AndroidStorageAdapterCpp::setDisk(const std::string& key, const std::string& value) {
64
- static auto method = AndroidStorageAdapterJava::javaClassStatic()->getStaticMethod<void(std::string, std::string)>("setDisk");
65
- method(AndroidStorageAdapterJava::javaClassStatic(), key, value);
82
+ static auto method = AndroidStorageAdapterJava::javaClassStatic()->getStaticMethod<void(alias_ref<JString>, alias_ref<JString>)>("setDisk");
83
+ method(AndroidStorageAdapterJava::javaClassStatic(), toJavaString(key), toJavaString(value));
66
84
  }
67
85
 
68
86
  std::optional<std::string> AndroidStorageAdapterCpp::getDisk(const std::string& key) {
69
- static auto method = AndroidStorageAdapterJava::javaClassStatic()->getStaticMethod<jstring(std::string)>("getDisk");
70
- auto result = method(AndroidStorageAdapterJava::javaClassStatic(), key);
87
+ static auto method = AndroidStorageAdapterJava::javaClassStatic()->getStaticMethod<jstring(alias_ref<JString>)>("getDisk");
88
+ auto result = method(AndroidStorageAdapterJava::javaClassStatic(), toJavaString(key));
71
89
  if (!result) return std::nullopt;
72
90
  return result->toStdString();
73
91
  }
74
92
 
75
93
  void AndroidStorageAdapterCpp::deleteDisk(const std::string& key) {
76
- static auto method = AndroidStorageAdapterJava::javaClassStatic()->getStaticMethod<void(std::string)>("deleteDisk");
77
- method(AndroidStorageAdapterJava::javaClassStatic(), key);
94
+ static auto method = AndroidStorageAdapterJava::javaClassStatic()->getStaticMethod<void(alias_ref<JString>)>("deleteDisk");
95
+ method(AndroidStorageAdapterJava::javaClassStatic(), toJavaString(key));
78
96
  }
79
97
 
80
98
  bool AndroidStorageAdapterCpp::hasDisk(const std::string& key) {
81
- static auto method = AndroidStorageAdapterJava::javaClassStatic()->getStaticMethod<jboolean(std::string)>("hasDisk");
82
- return method(AndroidStorageAdapterJava::javaClassStatic(), key);
99
+ static auto method = AndroidStorageAdapterJava::javaClassStatic()->getStaticMethod<jboolean(alias_ref<JString>)>("hasDisk");
100
+ return method(AndroidStorageAdapterJava::javaClassStatic(), toJavaString(key));
83
101
  }
84
102
 
85
103
  std::vector<std::string> AndroidStorageAdapterCpp::getAllKeysDisk() {
@@ -92,9 +110,9 @@ std::vector<std::string> AndroidStorageAdapterCpp::getAllKeysDisk() {
92
110
 
93
111
  std::vector<std::string> AndroidStorageAdapterCpp::getKeysByPrefixDisk(const std::string& prefix) {
94
112
  static auto method = AndroidStorageAdapterJava::javaClassStatic()->getStaticMethod<
95
- local_ref<JavaStringArray>(std::string)
113
+ local_ref<JavaStringArray>(alias_ref<JString>)
96
114
  >("getKeysByPrefixDisk");
97
- auto keys = method(AndroidStorageAdapterJava::javaClassStatic(), prefix);
115
+ auto keys = method(AndroidStorageAdapterJava::javaClassStatic(), toJavaString(prefix));
98
116
  return fromJavaStringArray(keys);
99
117
  }
100
118
 
@@ -145,25 +163,25 @@ void AndroidStorageAdapterCpp::clearDisk() {
145
163
  // --- Secure ---
146
164
 
147
165
  void AndroidStorageAdapterCpp::setSecure(const std::string& key, const std::string& value) {
148
- static auto method = AndroidStorageAdapterJava::javaClassStatic()->getStaticMethod<void(std::string, std::string)>("setSecure");
149
- method(AndroidStorageAdapterJava::javaClassStatic(), key, value);
166
+ static auto method = AndroidStorageAdapterJava::javaClassStatic()->getStaticMethod<void(alias_ref<JString>, alias_ref<JString>)>("setSecure");
167
+ method(AndroidStorageAdapterJava::javaClassStatic(), toJavaString(key), toJavaString(value));
150
168
  }
151
169
 
152
170
  std::optional<std::string> AndroidStorageAdapterCpp::getSecure(const std::string& key) {
153
- static auto method = AndroidStorageAdapterJava::javaClassStatic()->getStaticMethod<jstring(std::string)>("getSecure");
154
- auto result = method(AndroidStorageAdapterJava::javaClassStatic(), key);
171
+ static auto method = AndroidStorageAdapterJava::javaClassStatic()->getStaticMethod<jstring(alias_ref<JString>)>("getSecure");
172
+ auto result = method(AndroidStorageAdapterJava::javaClassStatic(), toJavaString(key));
155
173
  if (!result) return std::nullopt;
156
174
  return result->toStdString();
157
175
  }
158
176
 
159
177
  void AndroidStorageAdapterCpp::deleteSecure(const std::string& key) {
160
- static auto method = AndroidStorageAdapterJava::javaClassStatic()->getStaticMethod<void(std::string)>("deleteSecure");
161
- method(AndroidStorageAdapterJava::javaClassStatic(), key);
178
+ static auto method = AndroidStorageAdapterJava::javaClassStatic()->getStaticMethod<void(alias_ref<JString>)>("deleteSecure");
179
+ method(AndroidStorageAdapterJava::javaClassStatic(), toJavaString(key));
162
180
  }
163
181
 
164
182
  bool AndroidStorageAdapterCpp::hasSecure(const std::string& key) {
165
- static auto method = AndroidStorageAdapterJava::javaClassStatic()->getStaticMethod<jboolean(std::string)>("hasSecure");
166
- return method(AndroidStorageAdapterJava::javaClassStatic(), key);
183
+ static auto method = AndroidStorageAdapterJava::javaClassStatic()->getStaticMethod<jboolean(alias_ref<JString>)>("hasSecure");
184
+ return method(AndroidStorageAdapterJava::javaClassStatic(), toJavaString(key));
167
185
  }
168
186
 
169
187
  std::vector<std::string> AndroidStorageAdapterCpp::getAllKeysSecure() {
@@ -176,9 +194,9 @@ std::vector<std::string> AndroidStorageAdapterCpp::getAllKeysSecure() {
176
194
 
177
195
  std::vector<std::string> AndroidStorageAdapterCpp::getKeysByPrefixSecure(const std::string& prefix) {
178
196
  static auto method = AndroidStorageAdapterJava::javaClassStatic()->getStaticMethod<
179
- local_ref<JavaStringArray>(std::string)
197
+ local_ref<JavaStringArray>(alias_ref<JString>)
180
198
  >("getKeysByPrefixSecure");
181
- auto keys = method(AndroidStorageAdapterJava::javaClassStatic(), prefix);
199
+ auto keys = method(AndroidStorageAdapterJava::javaClassStatic(), toJavaString(prefix));
182
200
  return fromJavaStringArray(keys);
183
201
  }
184
202
 
@@ -242,25 +260,25 @@ void AndroidStorageAdapterCpp::setSecureBiometric(const std::string& key, const
242
260
  }
243
261
 
244
262
  void AndroidStorageAdapterCpp::setSecureBiometricWithLevel(const std::string& key, const std::string& value, int level) {
245
- static auto method = AndroidStorageAdapterJava::javaClassStatic()->getStaticMethod<void(std::string, std::string, jint)>("setSecureBiometricWithLevel");
246
- method(AndroidStorageAdapterJava::javaClassStatic(), key, value, level);
263
+ static auto method = AndroidStorageAdapterJava::javaClassStatic()->getStaticMethod<void(alias_ref<JString>, alias_ref<JString>, jint)>("setSecureBiometricWithLevel");
264
+ method(AndroidStorageAdapterJava::javaClassStatic(), toJavaString(key), toJavaString(value), level);
247
265
  }
248
266
 
249
267
  std::optional<std::string> AndroidStorageAdapterCpp::getSecureBiometric(const std::string& key) {
250
- static auto method = AndroidStorageAdapterJava::javaClassStatic()->getStaticMethod<jstring(std::string)>("getSecureBiometric");
251
- auto result = method(AndroidStorageAdapterJava::javaClassStatic(), key);
268
+ static auto method = AndroidStorageAdapterJava::javaClassStatic()->getStaticMethod<jstring(alias_ref<JString>)>("getSecureBiometric");
269
+ auto result = method(AndroidStorageAdapterJava::javaClassStatic(), toJavaString(key));
252
270
  if (!result) return std::nullopt;
253
271
  return result->toStdString();
254
272
  }
255
273
 
256
274
  void AndroidStorageAdapterCpp::deleteSecureBiometric(const std::string& key) {
257
- static auto method = AndroidStorageAdapterJava::javaClassStatic()->getStaticMethod<void(std::string)>("deleteSecureBiometric");
258
- method(AndroidStorageAdapterJava::javaClassStatic(), key);
275
+ static auto method = AndroidStorageAdapterJava::javaClassStatic()->getStaticMethod<void(alias_ref<JString>)>("deleteSecureBiometric");
276
+ method(AndroidStorageAdapterJava::javaClassStatic(), toJavaString(key));
259
277
  }
260
278
 
261
279
  bool AndroidStorageAdapterCpp::hasSecureBiometric(const std::string& key) {
262
- static auto method = AndroidStorageAdapterJava::javaClassStatic()->getStaticMethod<jboolean(std::string)>("hasSecureBiometric");
263
- return method(AndroidStorageAdapterJava::javaClassStatic(), key);
280
+ static auto method = AndroidStorageAdapterJava::javaClassStatic()->getStaticMethod<jboolean(alias_ref<JString>)>("hasSecureBiometric");
281
+ return method(AndroidStorageAdapterJava::javaClassStatic(), toJavaString(key));
264
282
  }
265
283
 
266
284
  void AndroidStorageAdapterCpp::clearSecureBiometric() {
@@ -103,9 +103,9 @@ internal class DiskSqliteStore(
103
103
  fun getKeysByPrefix(prefix: String): Array<String> {
104
104
  val pattern = buildString {
105
105
  for (character in prefix) {
106
- if (character == '%' || character == '_' || character == '\\') {
107
- append('\\')
108
- }
106
+ // LIKE stops at NUL; startsWith below checks the complete prefix.
107
+ if (character == '\u0000') break
108
+ if (character == '%' || character == '_' || character == '\\') append('\\')
109
109
  append(character)
110
110
  }
111
111
  append('%')
@@ -116,7 +116,10 @@ internal class DiskSqliteStore(
116
116
  ).use { cursor ->
117
117
  val keys = ArrayList<String>(cursor.count)
118
118
  while (cursor.moveToNext()) {
119
- keys.add(cursor.getString(0))
119
+ val key = cursor.getString(0)
120
+ if (key.startsWith(prefix)) {
121
+ keys.add(key)
122
+ }
120
123
  }
121
124
  return keys.toTypedArray()
122
125
  }
@@ -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;
@@ -336,8 +338,7 @@ std::vector<std::string> SqliteDiskStore::getKeysByPrefix(const std::string& pre
336
338
  std::lock_guard<std::mutex> lock(mutex_);
337
339
  sqlite3_reset(prefixStmt_);
338
340
  sqlite3_clear_bindings(prefixStmt_);
339
- const std::string pattern = escapeLikePrefix(prefix);
340
- bindText(prefixStmt_, 1, pattern);
341
+ bindText(prefixStmt_, 1, escapeLikePrefix(prefix));
341
342
  std::vector<std::string> keys;
342
343
  while (true) {
343
344
  const int rc = sqlite3_step(prefixStmt_);
@@ -350,10 +351,13 @@ std::vector<std::string> SqliteDiskStore::getKeysByPrefix(const std::string& pre
350
351
  }
351
352
  const unsigned char* text = sqlite3_column_text(prefixStmt_, 0);
352
353
  const int bytes = sqlite3_column_bytes(prefixStmt_, 0);
353
- keys.emplace_back(
354
+ std::string key(
354
355
  text != nullptr ? reinterpret_cast<const char*>(text) : "",
355
356
  static_cast<size_t>(bytes)
356
357
  );
358
+ if (key.compare(0, prefix.size(), prefix) == 0) {
359
+ keys.push_back(std::move(key));
360
+ }
357
361
  }
358
362
  sqlite3_reset(prefixStmt_);
359
363
  return keys;
@@ -44,3 +44,40 @@ bun run --cwd packages/react-native-nitro-storage check:pack
44
44
  ```
45
45
 
46
46
  Keep the dry-publish output in the release notes when validating a version locally.
47
+
48
+ ## 2026-09-27 Memory enumeration experiment
49
+
50
+ Command: `bun run benchmark:enumeration`. The smoke form, `bun run benchmark:enumeration -- --smoke`, validates fixture execution only.
51
+
52
+ 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.
53
+
54
+ 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.
55
+
56
+ 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):
57
+
58
+ | Keys | Operation | Run 1 | Run 2 |
59
+ | ------- | ------------- | ------ | ------ |
60
+ | 1,000 | `getAll` | -10.6% | -14.2% |
61
+ | 1,000 | `getByPrefix` | -8.4% | -3.7% |
62
+ | 10,000 | `getAll` | -10.4% | -7.4% |
63
+ | 10,000 | `getByPrefix` | -10.2% | -9.7% |
64
+ | 100,000 | `getAll` | -47.5% | -47.8% |
65
+ | 100,000 | `getByPrefix` | -12.2% | -6.9% |
66
+
67
+ 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.
68
+
69
+ 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.
70
+
71
+ An earlier ordinary-property shortcut was reverted after a 10k-key regression. An entry-tuple iteration candidate was replaced after elevated peak RSS.
72
+
73
+ Fixture SHA-256: `1ec2feb7ff55d5c61f33e4f70948336ca97d0a7746a77d9c295d7ae4417463f6`. Lockfile SHA-256: `1a6a54f48038afa313fb8a8316611f2091edf8a133bcc16173836599f1831492`. Corrected core SHA-256: `f7e8687b69d93a0fec8318877e6227b8b42ee617b68a257a17e33f83f4492b17`. Candidate core SHA-256: `e8b68c5bb3305b07eeaaff609381ec28ab564424a8fdbb53a8014f6460b6c86e`.
74
+
75
+ 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.
76
+
77
+ ### Original-release control and rejected prefix experiment
78
+
79
+ 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.
80
+
81
+ 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.
82
+
83
+ 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.