react-native-nitro-storage 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +32 -0
- package/README.md +20 -9
- package/SECURITY.md +2 -2
- package/docs/api-reference.md +11 -1
- package/docs/batch-transactions-migrations.md +8 -2
- package/docs/benchmarks.md +19 -15
- package/docs/web-backends.md +18 -1
- package/ios/IOSStorageAdapterCpp.mm +162 -17
- package/lib/commonjs/capabilities.js +26 -0
- package/lib/commonjs/capabilities.js.map +1 -0
- package/lib/commonjs/core/durability.js +137 -0
- package/lib/commonjs/core/durability.js.map +1 -0
- package/lib/commonjs/core/metrics.js +91 -0
- package/lib/commonjs/core/metrics.js.map +1 -0
- package/lib/commonjs/index.js +47 -22
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/index.web.js +118 -66
- package/lib/commonjs/index.web.js.map +1 -1
- package/lib/commonjs/indexeddb-backend.js +73 -23
- package/lib/commonjs/indexeddb-backend.js.map +1 -1
- package/lib/commonjs/internal.js +27 -4
- package/lib/commonjs/internal.js.map +1 -1
- package/lib/commonjs/shared.js +2 -2
- package/lib/commonjs/shared.js.map +1 -1
- package/lib/commonjs/storage-core.js +217 -233
- package/lib/commonjs/storage-core.js.map +1 -1
- package/lib/commonjs/storage-events.js.map +1 -1
- package/lib/commonjs/storage-platform.js +6 -0
- package/lib/commonjs/storage-platform.js.map +1 -0
- package/lib/commonjs/storage-runtime.js +3 -21
- package/lib/commonjs/storage-runtime.js.map +1 -1
- package/lib/commonjs/testing.js +19 -4
- package/lib/commonjs/testing.js.map +1 -1
- package/lib/commonjs/web-backend-contract.js +27 -0
- package/lib/commonjs/web-backend-contract.js.map +1 -0
- package/lib/module/capabilities.js +21 -0
- package/lib/module/capabilities.js.map +1 -0
- package/lib/module/core/durability.js +133 -0
- package/lib/module/core/durability.js.map +1 -0
- package/lib/module/core/metrics.js +87 -0
- package/lib/module/core/metrics.js.map +1 -0
- package/lib/module/index.js +48 -23
- package/lib/module/index.js.map +1 -1
- package/lib/module/index.web.js +103 -64
- package/lib/module/index.web.js.map +1 -1
- package/lib/module/indexeddb-backend.js +73 -23
- package/lib/module/indexeddb-backend.js.map +1 -1
- package/lib/module/internal.js +24 -3
- package/lib/module/internal.js.map +1 -1
- package/lib/module/shared.js +2 -2
- package/lib/module/shared.js.map +1 -1
- package/lib/module/storage-core.js +218 -234
- package/lib/module/storage-core.js.map +1 -1
- package/lib/module/storage-events.js.map +1 -1
- package/lib/module/storage-platform.js +4 -0
- package/lib/module/storage-platform.js.map +1 -0
- package/lib/module/storage-runtime.js +3 -21
- package/lib/module/storage-runtime.js.map +1 -1
- package/lib/module/testing.js +5 -3
- package/lib/module/testing.js.map +1 -1
- package/lib/module/web-backend-contract.js +22 -0
- package/lib/module/web-backend-contract.js.map +1 -0
- package/lib/typescript/capabilities.d.ts +11 -0
- package/lib/typescript/capabilities.d.ts.map +1 -0
- package/lib/typescript/core/durability.d.ts +27 -0
- package/lib/typescript/core/durability.d.ts.map +1 -0
- package/lib/typescript/core/metrics.d.ts +12 -0
- package/lib/typescript/core/metrics.d.ts.map +1 -0
- package/lib/typescript/index.d.ts +5 -2
- package/lib/typescript/index.d.ts.map +1 -1
- package/lib/typescript/index.web.d.ts +8 -4
- package/lib/typescript/index.web.d.ts.map +1 -1
- package/lib/typescript/indexeddb-backend.d.ts.map +1 -1
- package/lib/typescript/internal.d.ts +3 -0
- package/lib/typescript/internal.d.ts.map +1 -1
- package/lib/typescript/shared.d.ts +2 -1
- package/lib/typescript/shared.d.ts.map +1 -1
- package/lib/typescript/storage-core.d.ts +3 -3
- package/lib/typescript/storage-core.d.ts.map +1 -1
- package/lib/typescript/storage-events.d.ts +2 -2
- package/lib/typescript/storage-events.d.ts.map +1 -1
- package/lib/typescript/storage-platform.d.ts +12 -0
- package/lib/typescript/storage-platform.d.ts.map +1 -0
- package/lib/typescript/storage-runtime.d.ts.map +1 -1
- package/lib/typescript/testing.d.ts +5 -1
- package/lib/typescript/testing.d.ts.map +1 -1
- package/lib/typescript/web-backend-contract.d.ts +10 -0
- package/lib/typescript/web-backend-contract.d.ts.map +1 -0
- package/lib/typescript/web-storage-backend.d.ts +1 -1
- package/lib/typescript/web-storage-backend.d.ts.map +1 -1
- package/package.json +3 -2
- package/src/capabilities.ts +36 -0
- package/src/core/durability.ts +170 -0
- package/src/core/metrics.ts +133 -0
- package/src/index.ts +66 -40
- package/src/index.web.ts +158 -86
- package/src/indexeddb-backend.ts +91 -20
- package/src/internal.ts +50 -4
- package/src/shared.ts +7 -6
- package/src/storage-core.ts +317 -285
- package/src/storage-events.ts +2 -1
- package/src/storage-platform.ts +16 -0
- package/src/storage-runtime.ts +13 -47
- package/src/testing.ts +14 -7
- package/src/web-backend-contract.ts +34 -0
- package/src/web-storage-backend.ts +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,38 @@ All notable changes to this project are documented in this file.
|
|
|
4
4
|
|
|
5
5
|
The format follows Keep a Changelog and the project adheres to SemVer.
|
|
6
6
|
|
|
7
|
+
## 0.8.0 - 2026-08-12
|
|
8
|
+
|
|
9
|
+
### Breaking changes
|
|
10
|
+
|
|
11
|
+
- None. `getMetricsSnapshot()` keeps its unscoped, cross-scope aggregate keys.
|
|
12
|
+
Use the new `getScopedMetricsSnapshot()` when per-scope counters are needed.
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
|
|
16
|
+
- Exported `PlatformStorage` and `PlatformScope` types from the native, web,
|
|
17
|
+
and testing entrypoints so shared consumer code can verify platform parity
|
|
18
|
+
without duplicating the package contract.
|
|
19
|
+
- Added `getScopedMetricsSnapshot()` with keys such as `item:set:1`, without
|
|
20
|
+
changing established dashboards that consume `getMetricsSnapshot()`.
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
|
|
24
|
+
- **Data-loss prevention:** `storage.import()` now flushes pending coalesced Disk and Secure writes before writing, so a later scheduled flush can never overwrite imported values.
|
|
25
|
+
- **Biometric parity:** promoting a value to biometric storage removes the plain secure copy on iOS and web, matching Android; plain reads can no longer return stale values after promotion.
|
|
26
|
+
- **iOS legacy disk data:** Disk enumeration, size, prefix queries, and clear now cover both the suite domain and legacy `standardUserDefaults` values, so deleted legacy values cannot reappear.
|
|
27
|
+
- **Failure-atomic migrations:** each migration step runs in its own transaction with its version marker; a failed step rolls back its data and marker, and rerunning `migrateToLatest()` retries from the last completed version.
|
|
28
|
+
- **Transaction events:** failed transactions emit exactly one typed `rollback` batch event with pre-rollback and restored raw values.
|
|
29
|
+
- **Atomic memory batch removes:** `removeBatch()` in Memory scope mutates all keys first and emits a single `removeBatch` event.
|
|
30
|
+
- **Stable error classification:** storage error codes now come only from `[nitro-error:<code>]` tags produced by the native and web adapters; message-text scraping was removed and every public code has a producer.
|
|
31
|
+
- **Encoding collisions:** reserved primitive tokens and the native batch missing sentinel are escaped in the stored encoding; legacy reads are preserved and raw API round-trips are unchanged.
|
|
32
|
+
|
|
33
|
+
### Changed
|
|
34
|
+
|
|
35
|
+
- `getCapabilities().writeBuffering` now reports real per-mode durability: native Secure writes report buffering only while `setSecureWritesAsync(true)` is active on Android, and web backends report buffering only for IndexedDB-based backends.
|
|
36
|
+
- The IndexedDB backend reports affected keys when `flush()` fails and starts a best-effort flush on `pagehide` and hidden visibility changes.
|
|
37
|
+
- `setIfVersion()` is documented as optimistic (no backend-level atomicity); CAS guarantees are covered by race tests.
|
|
38
|
+
|
|
7
39
|
## 0.7.0 - 2026-07-30
|
|
8
40
|
|
|
9
41
|
### Changes
|
package/README.md
CHANGED
|
@@ -4,9 +4,9 @@
|
|
|
4
4
|
[](https://www.npmjs.com/package/react-native-nitro-storage)
|
|
5
5
|
[](https://github.com/JoaoPauloCMarra/react-native-nitro-storage/actions/workflows/ci.yml)
|
|
6
6
|
[](https://github.com/JoaoPauloCMarra/react-native-nitro-storage/blob/main/LICENSE)
|
|
7
|
-
[](https://reactnative.dev/)
|
|
8
|
-
[](https://docs.expo.dev/versions/
|
|
9
|
-
[](https://reactnative.dev/docs/0.86/getting-started-without-a-framework)
|
|
8
|
+
[](https://docs.expo.dev/versions/v57.0.0/)
|
|
9
|
+
[](https://nitro.margelo.com/)
|
|
10
10
|
[](https://www.typescriptlang.org/)
|
|
11
11
|
|
|
12
12
|
Synchronous Memory, Disk, and Secure storage for React Native, Expo development
|
|
@@ -57,12 +57,12 @@ Peer dependencies:
|
|
|
57
57
|
| ---------------------------- | ------------------ |
|
|
58
58
|
| `react` | `>=18.2.0` |
|
|
59
59
|
| `react-native` | `>=0.75.0` |
|
|
60
|
-
| `react-native-nitro-modules` | `>=0.36.
|
|
60
|
+
| `react-native-nitro-modules` | `>=0.36.5 <0.37.0` |
|
|
61
61
|
|
|
62
|
-
Nitro peer requirement: `react-native-nitro-modules >=0.36.
|
|
62
|
+
Nitro peer requirement: `react-native-nitro-modules >=0.36.5 <0.37.0`.
|
|
63
63
|
|
|
64
64
|
Validated example baseline: Expo SDK 57, React Native 0.86.2, React 19.2.3,
|
|
65
|
-
and Nitro Modules 0.36.
|
|
65
|
+
and Nitro Modules 0.36.5.
|
|
66
66
|
|
|
67
67
|
For Expo development builds:
|
|
68
68
|
|
|
@@ -183,7 +183,7 @@ const config = diskItem<{ theme: "light" | "dark"; compact: boolean }>({
|
|
|
183
183
|
});
|
|
184
184
|
|
|
185
185
|
config.merge({ compact: true }); // shallow object update
|
|
186
|
-
config.reset(); //
|
|
186
|
+
config.reset(); // deletes the stored key; the next read returns the default value
|
|
187
187
|
const loginMethod = memoryItem<string | null>({
|
|
188
188
|
key: "loginMethod",
|
|
189
189
|
defaultValue: null,
|
|
@@ -378,7 +378,9 @@ On Android 11 and newer, `BiometricLevel.BiometryOnly` and
|
|
|
378
378
|
`BiometricLevel.BiometryOrPasscode` use separate Keystore policies. Android 10
|
|
379
379
|
and older support `BiometryOrPasscode`; `BiometryOnly` throws
|
|
380
380
|
`biometric_unavailable` because those releases cannot safely enforce the
|
|
381
|
-
biometric-only distinction.
|
|
381
|
+
biometric-only distinction. Promoting a value to biometric storage removes the
|
|
382
|
+
plain secure copy on every platform, so plain reads cannot return a stale
|
|
383
|
+
value. Secure existence, discovery, and cleanup operations
|
|
382
384
|
can also throw when a protected store is locked or its key is invalidated. Catch
|
|
383
385
|
those failures and use `isKeychainLockedError()` when authentication-aware retry
|
|
384
386
|
behavior is appropriate.
|
|
@@ -431,10 +433,15 @@ storage.setMetricsObserver((event) => {
|
|
|
431
433
|
});
|
|
432
434
|
|
|
433
435
|
const metrics = storage.getMetricsSnapshot();
|
|
436
|
+
const scopedMetrics = storage.getScopedMetricsSnapshot();
|
|
434
437
|
storage.resetMetrics();
|
|
435
438
|
unsubscribe();
|
|
436
439
|
```
|
|
437
440
|
|
|
441
|
+
`getMetricsSnapshot()` aggregates each operation across scopes for backward
|
|
442
|
+
compatibility. `getScopedMetricsSnapshot()` adds the numeric scope suffix for
|
|
443
|
+
per-scope analysis, for example `item:set:1`.
|
|
444
|
+
|
|
438
445
|
Secure event observer values are redacted by default. Pass
|
|
439
446
|
`{ redactSecureValues: false }` only in trusted debug tooling where raw values
|
|
440
447
|
are safe to inspect.
|
|
@@ -484,7 +491,11 @@ migrateFromMMKV(mmkvInstance, themeItem);
|
|
|
484
491
|
```
|
|
485
492
|
|
|
486
493
|
`runTransaction(scope, callback)` rolls back every write made through the `tx`
|
|
487
|
-
context if the callback throws.
|
|
494
|
+
context if the callback throws, then emits one typed `rollback` batch event.
|
|
495
|
+
|
|
496
|
+
Each migration step runs in its own transaction with its version marker, so a
|
|
497
|
+
failed step leaves the scope on the last completed version and rerunning
|
|
498
|
+
`migrateToLatest()` retries deterministically.
|
|
488
499
|
|
|
489
500
|
## Web Backends
|
|
490
501
|
|
package/SECURITY.md
CHANGED
package/docs/api-reference.md
CHANGED
|
@@ -152,7 +152,7 @@ storage.setEventObserver((event) => {
|
|
|
152
152
|
|
|
153
153
|
`setEventObserver()` redacts Secure `oldValue` and `newValue` fields by default. Pass `{ redactSecureValues: false }` only for in-memory debugging paths that never persist logs. Raw `subscribe*()` APIs preserve values for state integrations.
|
|
154
154
|
|
|
155
|
-
Local batch APIs emit one `type: "batch"` envelope to scope and prefix/namespace listeners. Key subscribers receive the matching per-key change so direct key integrations do not need to unpack batch envelopes. Secure events can include raw secret values; do not log Secure event payloads in production.
|
|
155
|
+
Local batch APIs emit one `type: "batch"` envelope to scope and prefix/namespace listeners. Key subscribers receive the matching per-key change so direct key integrations do not need to unpack batch envelopes. Failed transactions emit one batch envelope with `operation: "rollback"` whose changes carry the pre-rollback and restored raw values. Secure events can include raw secret values; do not log Secure event payloads in production.
|
|
156
156
|
|
|
157
157
|
## Batch Operations
|
|
158
158
|
|
|
@@ -280,5 +280,15 @@ Common public types:
|
|
|
280
280
|
- `WebSecureStorageBackend`
|
|
281
281
|
- `WebStorageChangeEvent`
|
|
282
282
|
- `WebStorageScope`
|
|
283
|
+
- `PlatformStorage`
|
|
284
|
+
- `PlatformScope`
|
|
285
|
+
- `WebBackendCapabilities`
|
|
286
|
+
|
|
287
|
+
`getCapabilities().writeBuffering` describes real per-mode durability:
|
|
288
|
+
|
|
289
|
+
- Native: Disk writes are buffered by the platform (`SharedPreferences.apply()` on Android, `NSUserDefaults` on iOS). Secure writes are buffered only when `setSecureWritesAsync(true)` is active on Android; iOS Keychain writes are synchronous.
|
|
290
|
+
- Web: buffering follows the configured backend; IndexedDB backends are buffered, localStorage backends are synchronous.
|
|
291
|
+
|
|
292
|
+
`describeWebBackendCapabilities(backend)` reports a backend's `buffered`, `flushable`, `closable`, and `subscribable` capabilities from the same typed contract used by the built-in backends.
|
|
283
293
|
|
|
284
294
|
The IndexedDB subpath exports `createIndexedDBBackend()` and `IndexedDBBackendOptions`.
|
|
@@ -80,7 +80,7 @@ const snapshot = storage.export(StorageScope.Disk);
|
|
|
80
80
|
storage.import(snapshot, StorageScope.Disk);
|
|
81
81
|
```
|
|
82
82
|
|
|
83
|
-
For Memory scope, import is atomic: all keys are written before listeners fire.
|
|
83
|
+
For Memory scope, import is atomic: all keys are written before listeners fire. Disk and Secure imports flush pending coalesced writes first, so the imported snapshot cannot be overwritten by an earlier pending flush, then delegate to native or web batch paths.
|
|
84
84
|
|
|
85
85
|
Secure exports contain raw secret values. `storage.export(StorageScope.Secure)` requires `{ includeSecureValues: true }`; `storage.exportSecureUnsafe()` is the explicit equivalent. Do not log Secure exports or include them in diagnostics, analytics, crash reports, or support bundles.
|
|
86
86
|
|
|
@@ -124,7 +124,9 @@ Transaction context methods:
|
|
|
124
124
|
- `setItem(item, value)`
|
|
125
125
|
- `removeItem(item)`
|
|
126
126
|
|
|
127
|
-
If the callback throws, Nitro Storage restores the keys it changed during that transaction.
|
|
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
|
+
|
|
129
|
+
Memory-scope batch removes are atomic: all keys are deleted before listeners fire, and scope, key, and prefix subscribers receive a single `removeBatch` event.
|
|
128
130
|
|
|
129
131
|
## Migrations
|
|
130
132
|
|
|
@@ -157,6 +159,8 @@ migrateToLatest(StorageScope.Disk);
|
|
|
157
159
|
|
|
158
160
|
Migration context methods work with raw strings. Use item serializers manually when migrating structured data.
|
|
159
161
|
|
|
162
|
+
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.
|
|
163
|
+
|
|
160
164
|
```ts
|
|
161
165
|
registerMigration(3, (ctx) => {
|
|
162
166
|
const raw = ctx.getRaw("settings");
|
|
@@ -198,3 +202,5 @@ const didWrite = themeItem.setIfVersion(
|
|
|
198
202
|
```
|
|
199
203
|
|
|
200
204
|
`setIfVersion()` returns `false` if another write changed the item after `getWithVersion()`.
|
|
205
|
+
|
|
206
|
+
Compare-and-set is optimistic: the version is read, compared, and written without backend-level atomicity. Two writers on different runtimes can both pass the check between their reads and writes, so use it for single-runtime coordination or last-writer-wins-tolerant flows. There is no native compare-and-swap primitive.
|
package/docs/benchmarks.md
CHANGED
|
@@ -2,36 +2,40 @@
|
|
|
2
2
|
|
|
3
3
|
Benchmarks are release checks, not product promises. Use them to catch regressions on the local machine and CI image used by this repo.
|
|
4
4
|
|
|
5
|
-
Run:
|
|
5
|
+
Run from the repo root:
|
|
6
6
|
|
|
7
7
|
```sh
|
|
8
|
-
bun run
|
|
8
|
+
bun run build
|
|
9
|
+
bun run benchmark
|
|
9
10
|
```
|
|
10
11
|
|
|
11
|
-
|
|
12
|
+
## Scope: Web Only
|
|
13
|
+
|
|
14
|
+
`benchmark` loads `lib/commonjs/index.web.js` and measures the web entry against the localStorage backend. The `disk:` and `secure:` labels describe web scopes, not native Disk or Secure storage.
|
|
15
|
+
|
|
16
|
+
Native Disk/Secure baselines require a device or simulator run and are not part of this gate. Do not compare these numbers against native storage.
|
|
12
17
|
|
|
13
18
|
## Interpreting Results
|
|
14
19
|
|
|
15
20
|
- Compare results on the same machine and Node/Bun version.
|
|
16
|
-
- Treat large deltas as a prompt to inspect recent storage-runtime, serialization,
|
|
21
|
+
- Treat large deltas as a prompt to inspect recent storage-runtime, serialization, cache, or event changes.
|
|
17
22
|
- Do not compare web backend numbers against native secure storage numbers; they measure different systems.
|
|
18
|
-
- Secure storage performance depends on platform state, device lock state, biometric prompts, and Keystore/Keychain behavior.
|
|
19
23
|
|
|
20
24
|
## Release Checklist
|
|
21
25
|
|
|
22
26
|
Before publishing:
|
|
23
27
|
|
|
24
28
|
```sh
|
|
25
|
-
bun run
|
|
26
|
-
bun run
|
|
27
|
-
bun run
|
|
28
|
-
bun run
|
|
29
|
-
bun run test
|
|
30
|
-
bun run test
|
|
31
|
-
bun run
|
|
32
|
-
bun run
|
|
33
|
-
|
|
34
|
-
|
|
29
|
+
bun run codegen:check
|
|
30
|
+
bun run lint:check
|
|
31
|
+
bun run format:check
|
|
32
|
+
bun run typecheck
|
|
33
|
+
bun run test:types
|
|
34
|
+
bun run test
|
|
35
|
+
bun run test:cpp
|
|
36
|
+
bun run build
|
|
37
|
+
bun run benchmark
|
|
38
|
+
bun run --cwd packages/react-native-nitro-storage check:pack
|
|
35
39
|
```
|
|
36
40
|
|
|
37
41
|
Keep the dry-publish output in the release notes when validating a version locally.
|
package/docs/web-backends.md
CHANGED
|
@@ -38,7 +38,7 @@ Optional methods improve performance and observability:
|
|
|
38
38
|
|
|
39
39
|
`subscribe(listener)` should report `{ key, newValue }` changes. Use `key: null` when the whole backend is cleared.
|
|
40
40
|
|
|
41
|
-
`close()` should release backend-owned resources such as database handles or broadcast channels.
|
|
41
|
+
`close()` should release backend-owned resources such as database handles or broadcast channels. A replaced backend is retired instead of closed on the spot: `flushWebStorageBackends()` flushes it and then closes it, so queued async writes can commit before the connection goes away. Backends without `flush()` are closed immediately on replacement, and a retired backend whose flush fails stays retired for a later explicit retry.
|
|
42
42
|
|
|
43
43
|
## Disk Backend
|
|
44
44
|
|
|
@@ -100,6 +100,23 @@ Reads are synchronous because they are served from memory after initial load. Wr
|
|
|
100
100
|
|
|
101
101
|
The IndexedDB backend exposes `close()` and rejects later synchronous operations after it is closed.
|
|
102
102
|
|
|
103
|
+
### Persistence Lifecycle
|
|
104
|
+
|
|
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.
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
const backend = await createIndexedDBBackend("app-secure", "keyvalue", {
|
|
109
|
+
onError: (error) => {
|
|
110
|
+
console.error("IndexedDB secure storage failed", error);
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// Before assertions, navigation, or lifecycle boundaries:
|
|
115
|
+
await flushWebStorageBackends();
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Treat IndexedDB persistence as best-effort under abrupt termination: keep a durable copy of critical values elsewhere if they must survive an immediate page close.
|
|
119
|
+
|
|
103
120
|
## Cross-tab Updates
|
|
104
121
|
|
|
105
122
|
The IndexedDB backend uses `BroadcastChannel` when available. Other tabs receive cache invalidation events and update their in-memory copy.
|
|
@@ -8,6 +8,8 @@ namespace NitroStorage {
|
|
|
8
8
|
static NSString* const kKeychainService = @"com.nitrostorage.keychain";
|
|
9
9
|
static NSString* const kBiometricKeychainService = @"com.nitrostorage.biometric";
|
|
10
10
|
static NSString* const kDiskSuiteName = @"com.nitrostorage.disk";
|
|
11
|
+
static NSString* const kLegacyDiskKeysRegistryKey =
|
|
12
|
+
@"__nitro_storage_legacy_disk_keys__";
|
|
11
13
|
|
|
12
14
|
static std::runtime_error taggedStorageError(const char* code, const std::string& message) {
|
|
13
15
|
return std::runtime_error(
|
|
@@ -15,11 +17,69 @@ static std::runtime_error taggedStorageError(const char* code, const std::string
|
|
|
15
17
|
);
|
|
16
18
|
}
|
|
17
19
|
|
|
20
|
+
// Unknown keychain statuses are surfaced untagged (matching Android's
|
|
21
|
+
// cause-based wrapping), except errSecNotAvailable which means the keychain
|
|
22
|
+
// is unavailable until the device is first unlocked — a locked condition.
|
|
23
|
+
static std::runtime_error keychainStatusError(OSStatus status, const std::string& operation) {
|
|
24
|
+
if (status == errSecNotAvailable) {
|
|
25
|
+
return taggedStorageError(
|
|
26
|
+
"keychain_locked",
|
|
27
|
+
std::string("NitroStorage: ") + operation + " failed: keychain is unavailable until the device is unlocked (errSecNotAvailable)."
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
return std::runtime_error(
|
|
31
|
+
std::string("NitroStorage: ") + operation + " failed with status " + std::to_string(status)
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
18
35
|
static NSUserDefaults* NitroDiskDefaults() {
|
|
19
36
|
static NSUserDefaults* defaults = [[NSUserDefaults alloc] initWithSuiteName:kDiskSuiteName];
|
|
20
37
|
return defaults ?: [NSUserDefaults standardUserDefaults];
|
|
21
38
|
}
|
|
22
39
|
|
|
40
|
+
// --- Legacy disk key registry ---
|
|
41
|
+
// Versions before the suite domain stored Disk values in standardUserDefaults.
|
|
42
|
+
// The registry records legacy keys observed through the storage API so that
|
|
43
|
+
// clearDisk can remove both stores and deleted legacy values cannot reappear.
|
|
44
|
+
// Only keys observed through the storage API are ever registered; the host
|
|
45
|
+
// app's own standard defaults keys are never enumerated or touched.
|
|
46
|
+
|
|
47
|
+
static NSSet<NSString*>* registeredLegacyDiskKeys() {
|
|
48
|
+
NSArray* stored = [NitroDiskDefaults() stringArrayForKey:kLegacyDiskKeysRegistryKey];
|
|
49
|
+
return stored ? [NSSet setWithArray:stored] : [NSSet set];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
static void persistLegacyDiskKeys(NSSet<NSString*>* keys) {
|
|
53
|
+
if (keys.count == 0) {
|
|
54
|
+
[NitroDiskDefaults() removeObjectForKey:kLegacyDiskKeysRegistryKey];
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
[NitroDiskDefaults() setObject:[keys allObjects] forKey:kLegacyDiskKeysRegistryKey];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
static void registerLegacyDiskKey(NSString* key) {
|
|
61
|
+
NSMutableSet* keys = [registeredLegacyDiskKeys() mutableCopy];
|
|
62
|
+
[keys addObject:key];
|
|
63
|
+
persistLegacyDiskKeys(keys);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
static void unregisterLegacyDiskKeys(NSArray<NSString*>* keys) {
|
|
67
|
+
if (keys.count == 0) {
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
NSMutableSet* registered = [registeredLegacyDiskKeys() mutableCopy];
|
|
71
|
+
BOOL changed = NO;
|
|
72
|
+
for (NSString* key in keys) {
|
|
73
|
+
if ([registered containsObject:key]) {
|
|
74
|
+
[registered removeObject:key];
|
|
75
|
+
changed = YES;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (changed) {
|
|
79
|
+
persistLegacyDiskKeys(registered);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
23
83
|
// Prevents the Keychain from showing auth UI. On iOS 14+ kSecUseAuthenticationUIFail is
|
|
24
84
|
// deprecated; the correct replacement is an LAContext with interactionNotAllowed = YES.
|
|
25
85
|
static void disableKeychainInteraction(NSMutableDictionary* query) {
|
|
@@ -52,6 +112,7 @@ void IOSStorageAdapterCpp::setDisk(const std::string& key, const std::string& va
|
|
|
52
112
|
NSUserDefaults* standard = [NSUserDefaults standardUserDefaults];
|
|
53
113
|
if ([standard objectForKey:nsKey] != nil) {
|
|
54
114
|
[standard removeObjectForKey:nsKey];
|
|
115
|
+
unregisterLegacyDiskKeys(@[nsKey]);
|
|
55
116
|
}
|
|
56
117
|
}
|
|
57
118
|
|
|
@@ -66,6 +127,7 @@ std::optional<std::string> IOSStorageAdapterCpp::getDisk(const std::string& key)
|
|
|
66
127
|
if (legacyValue) {
|
|
67
128
|
[defaults setObject:legacyValue forKey:nsKey];
|
|
68
129
|
[legacyDefaults removeObjectForKey:nsKey];
|
|
130
|
+
unregisterLegacyDiskKeys(@[nsKey]);
|
|
69
131
|
result = legacyValue;
|
|
70
132
|
}
|
|
71
133
|
}
|
|
@@ -81,22 +143,40 @@ void IOSStorageAdapterCpp::deleteDisk(const std::string& key) {
|
|
|
81
143
|
if ([standard objectForKey:nsKey] != nil) {
|
|
82
144
|
[standard removeObjectForKey:nsKey];
|
|
83
145
|
}
|
|
146
|
+
unregisterLegacyDiskKeys(@[nsKey]);
|
|
84
147
|
}
|
|
85
148
|
|
|
86
149
|
bool IOSStorageAdapterCpp::hasDisk(const std::string& key) {
|
|
87
150
|
NSString* nsKey = [NSString stringWithUTF8String:key.c_str()];
|
|
88
151
|
if ([NitroDiskDefaults() objectForKey:nsKey] != nil) return true;
|
|
89
152
|
// Check legacy standardUserDefaults for un-migrated keys
|
|
90
|
-
|
|
153
|
+
if ([[NSUserDefaults standardUserDefaults] stringForKey:nsKey] != nil) {
|
|
154
|
+
registerLegacyDiskKey(nsKey);
|
|
155
|
+
return true;
|
|
156
|
+
}
|
|
157
|
+
return false;
|
|
91
158
|
}
|
|
92
159
|
|
|
93
160
|
std::vector<std::string> IOSStorageAdapterCpp::getAllKeysDisk() {
|
|
94
161
|
NSUserDefaults* defaults = NitroDiskDefaults();
|
|
95
162
|
NSDictionary<NSString*, id>* entries = [defaults persistentDomainForName:kDiskSuiteName] ?: @{};
|
|
96
|
-
std::
|
|
97
|
-
keys.reserve(entries.count);
|
|
163
|
+
std::unordered_set<std::string> combined;
|
|
98
164
|
for (NSString* key in entries) {
|
|
99
|
-
|
|
165
|
+
if (![key isEqualToString:kLegacyDiskKeysRegistryKey]) {
|
|
166
|
+
combined.insert(std::string([key UTF8String]));
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
// Only keys observed through the storage API are registered legacy keys;
|
|
170
|
+
// arbitrary host-app standard defaults keys are never enumerated.
|
|
171
|
+
for (NSString* key in [registeredLegacyDiskKeys() allObjects]) {
|
|
172
|
+
if (entries[key] == nil && ![key isEqualToString:kLegacyDiskKeysRegistryKey]) {
|
|
173
|
+
combined.insert(std::string([key UTF8String]));
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
std::vector<std::string> keys;
|
|
177
|
+
keys.reserve(combined.size());
|
|
178
|
+
for (const auto& key : combined) {
|
|
179
|
+
keys.push_back(key);
|
|
100
180
|
}
|
|
101
181
|
return keys;
|
|
102
182
|
}
|
|
@@ -114,8 +194,7 @@ std::vector<std::string> IOSStorageAdapterCpp::getKeysByPrefixDisk(const std::st
|
|
|
114
194
|
}
|
|
115
195
|
|
|
116
196
|
size_t IOSStorageAdapterCpp::sizeDisk() {
|
|
117
|
-
|
|
118
|
-
return entries.count;
|
|
197
|
+
return getAllKeysDisk().size();
|
|
119
198
|
}
|
|
120
199
|
|
|
121
200
|
void IOSStorageAdapterCpp::setDiskBatch(
|
|
@@ -136,6 +215,7 @@ void IOSStorageAdapterCpp::setDiskBatch(
|
|
|
136
215
|
for (NSString* key in legacyKeysToRemove) {
|
|
137
216
|
[standard removeObjectForKey:key];
|
|
138
217
|
}
|
|
218
|
+
unregisterLegacyDiskKeys(legacyKeysToRemove);
|
|
139
219
|
}
|
|
140
220
|
|
|
141
221
|
std::vector<std::optional<std::string>> IOSStorageAdapterCpp::getDiskBatch(
|
|
@@ -158,9 +238,26 @@ void IOSStorageAdapterCpp::deleteDiskBatch(const std::vector<std::string>& keys)
|
|
|
158
238
|
void IOSStorageAdapterCpp::clearDisk() {
|
|
159
239
|
NSUserDefaults* defaults = NitroDiskDefaults();
|
|
160
240
|
NSDictionary<NSString*, id>* entries = [defaults persistentDomainForName:kDiskSuiteName] ?: @{};
|
|
241
|
+
// Capture the registry before clearing the suite domain, which removes it.
|
|
242
|
+
NSMutableSet* legacyKeys = [registeredLegacyDiskKeys() mutableCopy];
|
|
161
243
|
for (NSString* key in entries) {
|
|
162
244
|
[defaults removeObjectForKey:key];
|
|
163
245
|
}
|
|
246
|
+
// Clear the standard-defaults copies of every storage-owned key so legacy
|
|
247
|
+
// values deleted here cannot reappear through the read-time migration path.
|
|
248
|
+
NSUserDefaults* standard = [NSUserDefaults standardUserDefaults];
|
|
249
|
+
for (NSString* key in entries) {
|
|
250
|
+
if ([standard objectForKey:key] != nil) {
|
|
251
|
+
[standard removeObjectForKey:key];
|
|
252
|
+
[legacyKeys removeObject:key];
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
for (NSString* key in legacyKeys) {
|
|
256
|
+
if ([standard objectForKey:key] != nil) {
|
|
257
|
+
[standard removeObjectForKey:key];
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
[defaults removeObjectForKey:kLegacyDiskKeysRegistryKey];
|
|
164
261
|
}
|
|
165
262
|
|
|
166
263
|
// --- Secure (Keychain) ---
|
|
@@ -228,9 +325,7 @@ static void setSecureValue(
|
|
|
228
325
|
"The item is not accessible until the device is unlocked."
|
|
229
326
|
);
|
|
230
327
|
}
|
|
231
|
-
throw
|
|
232
|
-
"NitroStorage: Secure set failed with status " + std::to_string(addStatus)
|
|
233
|
-
);
|
|
328
|
+
throw keychainStatusError(addStatus, "Secure set");
|
|
234
329
|
}
|
|
235
330
|
|
|
236
331
|
if (status == errSecInteractionNotAllowed) {
|
|
@@ -240,9 +335,7 @@ static void setSecureValue(
|
|
|
240
335
|
"The item is not accessible until the device is unlocked."
|
|
241
336
|
);
|
|
242
337
|
}
|
|
243
|
-
throw
|
|
244
|
-
"NitroStorage: Secure set failed with status " + std::to_string(status)
|
|
245
|
-
);
|
|
338
|
+
throw keychainStatusError(status, "Secure set");
|
|
246
339
|
}
|
|
247
340
|
|
|
248
341
|
static std::optional<std::string> getSecureValue(NSString* nsKey, NSString* group) {
|
|
@@ -290,6 +383,20 @@ static void deleteSecureValue(NSString* nsKey, NSString* group) {
|
|
|
290
383
|
}
|
|
291
384
|
}
|
|
292
385
|
|
|
386
|
+
// Deletes only the plain (non-biometric) keychain copy. Promotion to biometric
|
|
387
|
+
// storage must remove the plain copy so stale plain reads cannot resurrect it.
|
|
388
|
+
static void deletePlainSecureValue(NSString* nsKey, NSString* group) {
|
|
389
|
+
NSMutableDictionary* secureQuery = baseKeychainQuery(nsKey, kKeychainService, group);
|
|
390
|
+
OSStatus secureStatus = SecItemDelete((__bridge CFDictionaryRef)secureQuery);
|
|
391
|
+
if (secureStatus == errSecInteractionNotAllowed) {
|
|
392
|
+
throw taggedStorageError(
|
|
393
|
+
"keychain_locked",
|
|
394
|
+
"NitroStorage: Keychain is locked (errSecInteractionNotAllowed). "
|
|
395
|
+
"The item is not accessible until the device is unlocked."
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
293
400
|
static std::vector<std::string> keychainAccountsForService(NSString* service, NSString* accessGroup) {
|
|
294
401
|
NSMutableDictionary* query = allAccountsQuery(service, accessGroup);
|
|
295
402
|
disableKeychainInteraction(query);
|
|
@@ -490,8 +597,7 @@ void IOSStorageAdapterCpp::clearSecure() {
|
|
|
490
597
|
"NitroStorage: Cannot clear secure storage: keychain is locked (errSecInteractionNotAllowed)"
|
|
491
598
|
);
|
|
492
599
|
}
|
|
493
|
-
throw
|
|
494
|
-
std::string("NitroStorage: clearSecure failed with status ") + std::to_string(secStatus));
|
|
600
|
+
throw keychainStatusError(secStatus, "clearSecure");
|
|
495
601
|
}
|
|
496
602
|
|
|
497
603
|
NSMutableDictionary* biometricQuery = [@{
|
|
@@ -509,8 +615,7 @@ void IOSStorageAdapterCpp::clearSecure() {
|
|
|
509
615
|
"NitroStorage: Cannot clear biometric storage: keychain is locked (errSecInteractionNotAllowed)"
|
|
510
616
|
);
|
|
511
617
|
}
|
|
512
|
-
throw
|
|
513
|
-
std::string("NitroStorage: clearSecureBiometric failed with status ") + std::to_string(bioStatus));
|
|
618
|
+
throw keychainStatusError(bioStatus, "clearSecureBiometric");
|
|
514
619
|
}
|
|
515
620
|
clearSecureKeyCache(); // Only clears cache AFTER confirmed deletion
|
|
516
621
|
}
|
|
@@ -627,12 +732,52 @@ void IOSStorageAdapterCpp::setSecureBiometricWithLevel(const std::string& key, c
|
|
|
627
732
|
"The item is not accessible until the device is unlocked."
|
|
628
733
|
);
|
|
629
734
|
}
|
|
630
|
-
|
|
735
|
+
if (addStatus == errSecNotAvailable) {
|
|
736
|
+
throw taggedStorageError(
|
|
737
|
+
"keychain_locked",
|
|
738
|
+
"NitroStorage: Biometric set failed: keychain is unavailable until the device is unlocked (errSecNotAvailable)."
|
|
739
|
+
);
|
|
740
|
+
}
|
|
741
|
+
throw taggedStorageError(
|
|
742
|
+
"biometric_unavailable",
|
|
631
743
|
std::string("NitroStorage: Biometric set failed with status ") +
|
|
632
744
|
std::to_string(addStatus) +
|
|
633
745
|
(backup.has_value() ? " (previous value restored to non-biometric keychain)" : " (no previous value)"));
|
|
634
746
|
}
|
|
747
|
+
|
|
748
|
+
// Promotion contract: the plain secure copy must not survive a biometric
|
|
749
|
+
// write, or plain reads would return a stale value (Android removes it too).
|
|
750
|
+
// If removing the plain copy fails after the biometric write succeeded,
|
|
751
|
+
// roll the biometric write back so the promotion either fully succeeds or
|
|
752
|
+
// leaves the previous state intact, then rethrow the original error.
|
|
753
|
+
try {
|
|
754
|
+
deletePlainSecureValue(nsKey, group);
|
|
755
|
+
} catch (const std::exception& plainDeleteError) {
|
|
756
|
+
if (backup.has_value()) {
|
|
757
|
+
CFErrorRef restoreError = NULL;
|
|
758
|
+
SecAccessControlRef restoreAccess = SecAccessControlCreateWithFlags(
|
|
759
|
+
kCFAllocatorDefault,
|
|
760
|
+
kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly,
|
|
761
|
+
flags,
|
|
762
|
+
&restoreError
|
|
763
|
+
);
|
|
764
|
+
if (restoreAccess && !restoreError) {
|
|
765
|
+
NSMutableDictionary* restoreQuery = baseKeychainQuery(nsKey, kBiometricKeychainService, group);
|
|
766
|
+
restoreQuery[(__bridge id)kSecValueData] = [nsStringFromStdString(*backup) dataUsingEncoding:NSUTF8StringEncoding];
|
|
767
|
+
restoreQuery[(__bridge id)kSecAttrAccessControl] = (__bridge_transfer id)restoreAccess;
|
|
768
|
+
SecItemAdd((__bridge CFDictionaryRef)restoreQuery, NULL);
|
|
769
|
+
} else {
|
|
770
|
+
if (restoreError) CFRelease(restoreError);
|
|
771
|
+
if (restoreAccess) CFRelease(restoreAccess);
|
|
772
|
+
}
|
|
773
|
+
} else {
|
|
774
|
+
NSMutableDictionary* rollbackQuery = baseKeychainQuery(nsKey, kBiometricKeychainService, group);
|
|
775
|
+
SecItemDelete((__bridge CFDictionaryRef)rollbackQuery);
|
|
776
|
+
}
|
|
777
|
+
throw;
|
|
778
|
+
}
|
|
635
779
|
markBiometricKeySet(key);
|
|
780
|
+
markSecureKeyRemoved(key);
|
|
636
781
|
}
|
|
637
782
|
|
|
638
783
|
std::optional<std::string> IOSStorageAdapterCpp::getSecureBiometric(const std::string& key) {
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
Object.defineProperty(exports, "__esModule", {
|
|
4
|
+
value: true
|
|
5
|
+
});
|
|
6
|
+
exports.resolveNativeWriteBuffering = resolveNativeWriteBuffering;
|
|
7
|
+
exports.resolveWebWriteBuffering = resolveWebWriteBuffering;
|
|
8
|
+
function resolveWebWriteBuffering(diskBackendName, secureBackendName) {
|
|
9
|
+
return {
|
|
10
|
+
disk: isIndexedDBWebBackendByName(diskBackendName),
|
|
11
|
+
secure: isIndexedDBWebBackendByName(secureBackendName)
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
function isIndexedDBWebBackendByName(backendName) {
|
|
15
|
+
if (!backendName) {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
return backendName.startsWith("indexeddb:");
|
|
19
|
+
}
|
|
20
|
+
function resolveNativeWriteBuffering(platform, secureWritesAsync) {
|
|
21
|
+
return {
|
|
22
|
+
disk: true,
|
|
23
|
+
secure: platform === "android" ? secureWritesAsync : false
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
//# sourceMappingURL=capabilities.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"names":["resolveWebWriteBuffering","diskBackendName","secureBackendName","disk","isIndexedDBWebBackendByName","secure","backendName","startsWith","resolveNativeWriteBuffering","platform","secureWritesAsync"],"sourceRoot":"../../src","sources":["capabilities.ts"],"mappings":";;;;;;;AAUO,SAASA,wBAAwBA,CACtCC,eAAmC,EACnCC,iBAAqC,EACrB;EAChB,OAAO;IACLC,IAAI,EAAEC,2BAA2B,CAACH,eAAe,CAAC;IAClDI,MAAM,EAAED,2BAA2B,CAACF,iBAAiB;EACvD,CAAC;AACH;AAEA,SAASE,2BAA2BA,CAACE,WAA+B,EAAW;EAC7E,IAAI,CAACA,WAAW,EAAE;IAChB,OAAO,KAAK;EACd;EACA,OAAOA,WAAW,CAACC,UAAU,CAAC,YAAY,CAAC;AAC7C;AAEO,SAASC,2BAA2BA,CACzCC,QAA2B,EAC3BC,iBAA0B,EACV;EAChB,OAAO;IACLP,IAAI,EAAE,IAAI;IACVE,MAAM,EAAEI,QAAQ,KAAK,SAAS,GAAGC,iBAAiB,GAAG;EACvD,CAAC;AACH","ignoreList":[]}
|