react-native-nitro-storage 0.10.2 → 0.10.3

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 (40) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +17 -8
  3. package/android/CMakeLists.txt +2 -0
  4. package/android/src/main/java/com/nitrostorage/AndroidStorageAdapter.kt +12 -25
  5. package/android/src/main/java/com/nitrostorage/DiskSqliteStore.kt +171 -0
  6. package/cpp/core/SqliteDiskStore.cpp +412 -0
  7. package/cpp/core/SqliteDiskStore.hpp +69 -0
  8. package/docs/api-reference.md +4 -1
  9. package/docs/native-libraries.md +30 -0
  10. package/docs/secure-storage.md +5 -0
  11. package/ios/IOSStorageAdapterCpp.mm +98 -18
  12. package/lib/commonjs/index.js.map +1 -1
  13. package/lib/commonjs/index.web.js.map +1 -1
  14. package/lib/commonjs/shared.js.map +1 -1
  15. package/lib/commonjs/storage-core.js +31 -0
  16. package/lib/commonjs/storage-core.js.map +1 -1
  17. package/lib/commonjs/testing.js.map +1 -1
  18. package/lib/module/index.js.map +1 -1
  19. package/lib/module/index.web.js.map +1 -1
  20. package/lib/module/shared.js.map +1 -1
  21. package/lib/module/storage-core.js +31 -0
  22. package/lib/module/storage-core.js.map +1 -1
  23. package/lib/module/testing.js.map +1 -1
  24. package/lib/typescript/index.d.ts +2 -1
  25. package/lib/typescript/index.d.ts.map +1 -1
  26. package/lib/typescript/index.web.d.ts +2 -1
  27. package/lib/typescript/index.web.d.ts.map +1 -1
  28. package/lib/typescript/shared.d.ts +6 -0
  29. package/lib/typescript/shared.d.ts.map +1 -1
  30. package/lib/typescript/storage-core.d.ts +2 -1
  31. package/lib/typescript/storage-core.d.ts.map +1 -1
  32. package/lib/typescript/testing.d.ts +3 -1
  33. package/lib/typescript/testing.d.ts.map +1 -1
  34. package/package.json +1 -1
  35. package/react-native-nitro-storage.podspec +2 -0
  36. package/src/index.ts +1 -0
  37. package/src/index.web.ts +1 -0
  38. package/src/shared.ts +7 -0
  39. package/src/storage-core.ts +33 -0
  40. package/src/testing.ts +1 -0
package/CHANGELOG.md CHANGED
@@ -6,6 +6,37 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
  Breaking changes are always listed first in each release section.
8
8
 
9
+ ## [0.10.3] - 2026-09-18
10
+
11
+ ### Breaking changes
12
+
13
+ - None.
14
+
15
+ ### Added
16
+
17
+ - `storage.getCacheMetrics()` reports live Disk/Secure raw-cache hits, misses,
18
+ entries, and estimated bytes. The cache stays unbounded; reset zeros the
19
+ counters only.
20
+ - Example integrity, keychain, and Disk/Secure stress labs at
21
+ `nitrostorage://e2e-integrity`, `nitrostorage://e2e-keychain`, and
22
+ `nitrostorage://e2e-stress`.
23
+ - Native Disk now uses SQLite WAL (`SqliteDiskStore` on iOS, `DiskSqliteStore`
24
+ on Android). Existing UserDefaults suite keys and `NitroStorage`
25
+ SharedPreferences values are imported once. See
26
+ [docs/native-libraries.md](docs/native-libraries.md).
27
+
28
+ ### Changed
29
+
30
+ - The example Expo pin follows SDK 57.0.24 (`expo-doctor` / `expo install --check`).
31
+ React Native stays `0.86.3`.
32
+ - The example iOS host uses a `SceneDelegate` so the app can present a window on
33
+ iOS 27 physical devices.
34
+
35
+ ### Fixed
36
+
37
+ - Android Disk no longer crashes on first open when enabling SQLite WAL:
38
+ `PRAGMA journal_mode=WAL` is applied with `rawQuery` instead of `execSQL`.
39
+
9
40
  ## [0.10.2] - 2026-09-10
10
41
 
11
42
  ### Breaking changes
package/README.md CHANGED
@@ -68,7 +68,7 @@ Nitro peer requirement: `react-native-nitro-modules >=0.37.0 <0.38.0`.
68
68
  The package gate uses React Native `0.86.3` and the Strict TypeScript API.
69
69
  `check:ci` also compiles the public source against React Native `0.87.0`'s
70
70
  Strict TypeScript API; this does not change the runtime baseline. The Expo
71
- example uses Expo SDK `57.0.21`, React Native
71
+ example uses Expo SDK `57.0.24`, React Native
72
72
  `0.86.3`, React `19.2.3`, and Nitro Modules `0.37.1`, which is the React Native
73
73
  version supported by that Expo SDK. Do not override Expo's React Native version.
74
74
 
@@ -77,7 +77,7 @@ before installing this package, then rebuild the native app so the generated
77
77
  Nitro bindings and native runtime use the same major-minor version:
78
78
 
79
79
  ```sh
80
- bun add react-native-nitro-modules@0.37.1 react-native-nitro-storage@0.10.2
80
+ bun add react-native-nitro-modules@0.37.1 react-native-nitro-storage@0.10.3
81
81
  bunx expo prebuild
82
82
  ```
83
83
 
@@ -387,11 +387,11 @@ const tokenActions = useStorageActions(tokenItem); // { set, merge, reset, remov
387
387
 
388
388
  ## Storage Scopes
389
389
 
390
- | Scope | Backing store | Use it for |
391
- | --------------------- | ------------------------------------------------------ | ---------------------------------------------------------------------------- |
392
- | `StorageScope.Memory` | In-process memory | Session-only state, fast counters, and render-time caches. |
393
- | `StorageScope.Disk` | UserDefaults on iOS, SharedPreferences on Android, web | Preferences, feature flags, onboarding state, and non-secret persisted data. |
394
- | `StorageScope.Secure` | Keychain on iOS, Android Keystore-backed preferences | Refresh tokens, credentials, API tokens, and biometric-protected values. |
390
+ | Scope | Backing store | Use it for |
391
+ | --------------------- | ------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- |
392
+ | `StorageScope.Memory` | In-process memory | Session-only state, fast counters, and render-time caches. |
393
+ | `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
+ | `StorageScope.Secure` | Keychain on iOS, Android Keystore-backed preferences | Refresh tokens, credentials, API tokens, and biometric-protected values. |
395
395
 
396
396
  ## Secure Storage
397
397
 
@@ -520,13 +520,21 @@ storage.setMetricsObserver((event) => {
520
520
 
521
521
  const metrics = storage.getMetricsSnapshot();
522
522
  const scopedMetrics = storage.getScopedMetricsSnapshot();
523
+ const cacheMetrics = storage.getCacheMetrics();
523
524
  storage.resetMetrics();
524
525
  unsubscribe();
525
526
  ```
526
527
 
527
528
  `getMetricsSnapshot()` aggregates each operation across scopes for backward
528
529
  compatibility. `getScopedMetricsSnapshot()` adds the numeric scope suffix for
529
- per-scope analysis, for example `item:set:1`.
530
+ per-scope analysis, for example `item:set:1`. `getCacheMetrics()` reports live
531
+ Disk/Secure raw-cache hits, misses, entries, and estimated bytes. The cache is
532
+ unbounded; `resetMetrics()` zeros the hit/miss counters and leaves entries in
533
+ place.
534
+
535
+ The example app includes hidden integrity, keychain, and Disk/Secure stress
536
+ labs at `nitrostorage://e2e-integrity`, `nitrostorage://e2e-keychain`, and
537
+ `nitrostorage://e2e-stress`.
530
538
 
531
539
  Secure event observer values are redacted by default. Pass
532
540
  `{ redactSecureValues: false }` only in trusted debug tooling where raw values
@@ -674,6 +682,7 @@ the error for diagnostics.
674
682
  | Web backends | [docs/web-backends.md](docs/web-backends.md) |
675
683
  | Batch, transactions, and migrations | [docs/batch-transactions-migrations.md](docs/batch-transactions-migrations.md) |
676
684
  | MMKV migration | [docs/mmkv-migration.md](docs/mmkv-migration.md) |
685
+ | Native libraries | [docs/native-libraries.md](docs/native-libraries.md) |
677
686
  | Recipes | [docs/recipes.md](docs/recipes.md) |
678
687
  | Benchmarks | [docs/benchmarks.md](docs/benchmarks.md) |
679
688
  | Security policy | [SECURITY.md](SECURITY.md) |
@@ -13,6 +13,8 @@ file(GLOB SOURCES
13
13
  )
14
14
  # Unit/integration C++ tests define `main()` and must never be linked into the Android shared library.
15
15
  list(FILTER SOURCES EXCLUDE REGEX ".*/[^/]*Test\\.cpp$")
16
+ # iOS/macOS Disk SQLite lives in this translation unit; Android uses DiskSqliteStore.kt.
17
+ list(FILTER SOURCES EXCLUDE REGEX ".*/SqliteDiskStore\\.cpp$")
16
18
 
17
19
  # 2. Create the library target
18
20
  add_library(
@@ -61,6 +61,7 @@ private fun Throwable.wrapStorageException(
61
61
  class AndroidStorageAdapter private constructor(private val context: Context) {
62
62
  private val sharedPreferences: SharedPreferences =
63
63
  context.getSharedPreferences("NitroStorage", Context.MODE_PRIVATE)
64
+ private val diskStore: DiskSqliteStore = DiskSqliteStore(context, sharedPreferences)
64
65
 
65
66
  private val masterKeyAlias = "${context.packageName}.nitro_storage.master_key"
66
67
 
@@ -519,71 +520,57 @@ class AndroidStorageAdapter private constructor(private val context: Context) {
519
520
 
520
521
  @JvmStatic
521
522
  fun setDisk(key: String, value: String) {
522
- getInstanceOrThrow().sharedPreferences.edit().putString(key, value).apply()
523
+ getInstanceOrThrow().diskStore.set(key, value)
523
524
  }
524
525
 
525
526
  @JvmStatic
526
527
  fun setDiskBatch(keys: Array<String>, values: Array<String>) {
527
- val editor = getInstanceOrThrow().sharedPreferences.edit()
528
- val count = minOf(keys.size, values.size)
529
- for (index in 0 until count) {
530
- editor.putString(keys[index], values[index])
531
- }
532
- editor.apply()
528
+ getInstanceOrThrow().diskStore.setBatch(keys, values)
533
529
  }
534
530
 
535
531
  @JvmStatic
536
532
  fun getDisk(key: String): String? {
537
- return getInstanceOrThrow().sharedPreferences.getString(key, null)
533
+ return getInstanceOrThrow().diskStore.get(key)
538
534
  }
539
535
 
540
536
  @JvmStatic
541
537
  fun getDiskBatch(keys: Array<String>): Array<String?> {
542
- val prefs = getInstanceOrThrow().sharedPreferences
543
- return Array(keys.size) { index ->
544
- prefs.getString(keys[index], null)
545
- }
538
+ return getInstanceOrThrow().diskStore.getBatch(keys)
546
539
  }
547
540
 
548
541
  @JvmStatic
549
542
  fun deleteDisk(key: String) {
550
- getInstanceOrThrow().sharedPreferences.edit().remove(key).apply()
543
+ getInstanceOrThrow().diskStore.remove(key)
551
544
  }
552
545
 
553
546
  @JvmStatic
554
547
  fun deleteDiskBatch(keys: Array<String>) {
555
- val editor = getInstanceOrThrow().sharedPreferences.edit()
556
- for (key in keys) {
557
- editor.remove(key)
558
- }
559
- editor.apply()
548
+ getInstanceOrThrow().diskStore.removeBatch(keys)
560
549
  }
561
550
 
562
551
  @JvmStatic
563
552
  fun hasDisk(key: String): Boolean {
564
- return getInstanceOrThrow().sharedPreferences.contains(key)
553
+ return getInstanceOrThrow().diskStore.has(key)
565
554
  }
566
555
 
567
556
  @JvmStatic
568
557
  fun getAllKeysDisk(): Array<String> {
569
- return getInstanceOrThrow().sharedPreferences.all.keys.toTypedArray()
558
+ return getInstanceOrThrow().diskStore.getAllKeys()
570
559
  }
571
560
 
572
561
  @JvmStatic
573
562
  fun getKeysByPrefixDisk(prefix: String): Array<String> {
574
- return getInstanceOrThrow().sharedPreferences.all.keys
575
- .filter { it.startsWith(prefix) }
576
- .toTypedArray()
563
+ return getInstanceOrThrow().diskStore.getKeysByPrefix(prefix)
577
564
  }
578
565
 
579
566
  @JvmStatic
580
567
  fun sizeDisk(): Int {
581
- return getInstanceOrThrow().sharedPreferences.all.size
568
+ return getInstanceOrThrow().diskStore.size()
582
569
  }
583
570
 
584
571
  @JvmStatic
585
572
  fun clearDisk() {
586
- getInstanceOrThrow().sharedPreferences.edit().clear().apply()
573
+ getInstanceOrThrow().diskStore.clear()
587
574
  }
588
575
 
589
576
  // --- Secure (async apply by default, sync commit when requested) ---
@@ -0,0 +1,171 @@
1
+ package com.nitrostorage
2
+
3
+ import android.content.Context
4
+ import android.content.SharedPreferences
5
+ import android.database.sqlite.SQLiteDatabase
6
+ import java.io.File
7
+
8
+ internal class DiskSqliteStore(
9
+ context: Context,
10
+ private val legacyPreferences: SharedPreferences,
11
+ ) {
12
+ private val db: SQLiteDatabase
13
+
14
+ init {
15
+ val file = File(context.filesDir, DATABASE_NAME)
16
+ db = SQLiteDatabase.openOrCreateDatabase(file, null)
17
+ // PRAGMA journal_mode returns a row; Android forbids result-bearing SQL on execSQL.
18
+ db.rawQuery("PRAGMA journal_mode=WAL", null).close()
19
+ db.execSQL("PRAGMA synchronous=NORMAL")
20
+ db.execSQL(
21
+ "CREATE TABLE IF NOT EXISTS kv (key TEXT PRIMARY KEY NOT NULL, value TEXT NOT NULL)",
22
+ )
23
+ db.execSQL(
24
+ "CREATE TABLE IF NOT EXISTS meta (k TEXT PRIMARY KEY NOT NULL, v TEXT NOT NULL)",
25
+ )
26
+ migrateLegacyPreferences()
27
+ }
28
+
29
+ @Synchronized
30
+ fun set(key: String, value: String) {
31
+ db.execSQL(
32
+ "INSERT OR REPLACE INTO kv(key, value) VALUES(?, ?)",
33
+ arrayOf(key, value),
34
+ )
35
+ }
36
+
37
+ @Synchronized
38
+ fun get(key: String): String? {
39
+ db.rawQuery("SELECT value FROM kv WHERE key = ? LIMIT 1", arrayOf(key)).use { cursor ->
40
+ return if (cursor.moveToFirst()) cursor.getString(0) else null
41
+ }
42
+ }
43
+
44
+ @Synchronized
45
+ fun remove(key: String) {
46
+ db.execSQL("DELETE FROM kv WHERE key = ?", arrayOf(key))
47
+ }
48
+
49
+ @Synchronized
50
+ fun has(key: String): Boolean {
51
+ db.rawQuery("SELECT 1 FROM kv WHERE key = ? LIMIT 1", arrayOf(key)).use { cursor ->
52
+ return cursor.moveToFirst()
53
+ }
54
+ }
55
+
56
+ @Synchronized
57
+ fun setBatch(keys: Array<String>, values: Array<String>) {
58
+ val count = minOf(keys.size, values.size)
59
+ db.beginTransaction()
60
+ try {
61
+ for (index in 0 until count) {
62
+ db.execSQL(
63
+ "INSERT OR REPLACE INTO kv(key, value) VALUES(?, ?)",
64
+ arrayOf(keys[index], values[index]),
65
+ )
66
+ }
67
+ db.setTransactionSuccessful()
68
+ } finally {
69
+ db.endTransaction()
70
+ }
71
+ }
72
+
73
+ @Synchronized
74
+ fun getBatch(keys: Array<String>): Array<String?> {
75
+ return Array(keys.size) { index -> get(keys[index]) }
76
+ }
77
+
78
+ @Synchronized
79
+ fun removeBatch(keys: Array<String>) {
80
+ db.beginTransaction()
81
+ try {
82
+ for (key in keys) {
83
+ db.execSQL("DELETE FROM kv WHERE key = ?", arrayOf(key))
84
+ }
85
+ db.setTransactionSuccessful()
86
+ } finally {
87
+ db.endTransaction()
88
+ }
89
+ }
90
+
91
+ @Synchronized
92
+ fun getAllKeys(): Array<String> {
93
+ db.rawQuery("SELECT key FROM kv", null).use { cursor ->
94
+ val keys = ArrayList<String>(cursor.count)
95
+ while (cursor.moveToNext()) {
96
+ keys.add(cursor.getString(0))
97
+ }
98
+ return keys.toTypedArray()
99
+ }
100
+ }
101
+
102
+ @Synchronized
103
+ fun getKeysByPrefix(prefix: String): Array<String> {
104
+ val pattern = buildString {
105
+ for (character in prefix) {
106
+ if (character == '%' || character == '_' || character == '\\') {
107
+ append('\\')
108
+ }
109
+ append(character)
110
+ }
111
+ append('%')
112
+ }
113
+ db.rawQuery(
114
+ "SELECT key FROM kv WHERE key LIKE ? ESCAPE '\\'",
115
+ arrayOf(pattern),
116
+ ).use { cursor ->
117
+ val keys = ArrayList<String>(cursor.count)
118
+ while (cursor.moveToNext()) {
119
+ keys.add(cursor.getString(0))
120
+ }
121
+ return keys.toTypedArray()
122
+ }
123
+ }
124
+
125
+ @Synchronized
126
+ fun size(): Int {
127
+ db.rawQuery("SELECT COUNT(*) FROM kv", null).use { cursor ->
128
+ return if (cursor.moveToFirst()) cursor.getInt(0) else 0
129
+ }
130
+ }
131
+
132
+ @Synchronized
133
+ fun clear() {
134
+ db.execSQL("DELETE FROM kv")
135
+ }
136
+
137
+ private fun migrateLegacyPreferences() {
138
+ db.rawQuery(
139
+ "SELECT v FROM meta WHERE k = ? LIMIT 1",
140
+ arrayOf(PREFS_MIGRATION_KEY),
141
+ ).use { cursor ->
142
+ if (cursor.moveToFirst() && cursor.getString(0) == "1") {
143
+ return
144
+ }
145
+ }
146
+
147
+ db.beginTransaction()
148
+ try {
149
+ for ((key, value) in legacyPreferences.all) {
150
+ if (value is String) {
151
+ db.execSQL(
152
+ "INSERT OR IGNORE INTO kv(key, value) VALUES(?, ?)",
153
+ arrayOf(key, value),
154
+ )
155
+ }
156
+ }
157
+ db.execSQL(
158
+ "INSERT OR REPLACE INTO meta(k, v) VALUES(?, ?)",
159
+ arrayOf(PREFS_MIGRATION_KEY, "1"),
160
+ )
161
+ db.setTransactionSuccessful()
162
+ } finally {
163
+ db.endTransaction()
164
+ }
165
+ }
166
+
167
+ private companion object {
168
+ const val DATABASE_NAME = "nitro-storage-disk.sqlite"
169
+ const val PREFS_MIGRATION_KEY = "prefs_v1"
170
+ }
171
+ }