Skip to content

Commit 8fb691d

Browse files
Merge pull request #14 from JoaoPauloCMarra/codex/prepare-0-4-5-release
feat: prepare 0.4.5 release
2 parents cf53f57 + 689a802 commit 8fb691d

18 files changed

Lines changed: 2503 additions & 358 deletions

File tree

CHANGELOG.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,17 @@ All notable changes to this project are documented in this file.
44

55
The format follows Keep a Changelog and the project adheres to SemVer.
66

7-
## 0.4.4 - 2026-04-08
7+
## 0.4.5 - 2026-04-14
8+
9+
### Added
10+
11+
- Add configurable web Disk backend hooks: `setWebDiskStorageBackend()`, `getWebDiskStorageBackend()`, and `flushWebStorageBackends()`.
12+
- Extend the web backend contract with optional batch, sizing, subscription, and flush hooks for higher-performance custom backends.
13+
- Add IndexedDB backend support for `getMany`, `setMany`, `removeMany`, `size`, `flush`, and `BroadcastChannel`-based cross-tab sync.
14+
- Expand regression coverage for web backend overrides, backend subscription-driven cache invalidation, backend flush hooks, IndexedDB broadcast sync, and IndexedDB error surfacing.
15+
- Add Disk write buffering APIs: `coalesceDiskWrites`, `storage.setDiskWritesAsync()`, `storage.flushDiskWrites()`, and `storage.getCapabilities()`.
16+
- Add structured storage error classification via `getStorageErrorCode()` while keeping `isKeychainLockedError()` as the convenience helper, and tag native bridge errors with stable `[nitro-error:<code>]` markers.
17+
- Extend the example app and smoke runner to cover runtime capabilities, structured error codes, and Disk write buffering flows.
818

919
### Changed
1020

@@ -14,6 +24,8 @@ The format follows Keep a Changelog and the project adheres to SemVer.
1424
- Refresh root tooling to current patch releases for linting, testing, and workspace orchestration.
1525
- Align the example app to `react-native-nitro-modules 0.35.4`.
1626
- Add an example-only Expo config plugin that patches the generated iOS `fmt` pod during `pod install`, keeping clean prebuilds working on Xcode 26.4.
27+
- Switch web operation timing to `performance.now()` when available for tighter metrics on fast paths.
28+
- Keep the example smoke runner aligned with the expanded web backend API surface, including backend override and flush coverage on web.
1729

1830
## 0.4.2/0.4.3 - 2026-03-05
1931

README.md

Lines changed: 107 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,11 @@ Every feature in this package is documented with at least one runnable example i
4040
- Prefix utilities (`getKeysByPrefix`, `getByPrefix`) — see Prefix Queries and Namespace Inspection
4141
- Versioned item API (`getWithVersion`, `setIfVersion`) — see Optimistic Versioned Writes
4242
- Metrics API (`setMetricsObserver`, `getMetricsSnapshot`, `resetMetrics`) — see Storage Metrics Instrumentation
43+
- Runtime capability introspection (`storage.getCapabilities()`) — see Global utility examples
44+
- Structured storage error codes (`getStorageErrorCode`, `isKeychainLockedError`) — see Error Classification
45+
- Web disk backend override (`setWebDiskStorageBackend`, `getWebDiskStorageBackend`) — see Custom Web Disk and Secure Backends
4346
- Web secure backend override (`setWebSecureStorageBackend`, `getWebSecureStorageBackend`) — see Custom Web Secure Backend
47+
- Web backend durability (`flushWebStorageBackends`) — see Custom Web Disk and Secure Backends
4448
- IndexedDB backend factory (`createIndexedDBBackend`) — see IndexedDB Backend for Web
4549
- Bulk import (`storage.import`) — see Bulk Data Import
4650
- Batch APIs (`getBatch`, `setBatch`, `removeBatch`) — see Batch Operations and Bulk Bootstrap with Batch APIs
@@ -196,6 +200,7 @@ function createStorageItem<T = undefined>(
196200
| `expiration` | `{ ttlMs: number }` || Time-to-live in milliseconds |
197201
| `onExpired` | `(key: string) => void` || Callback fired when a TTL value expires on read |
198202
| `readCache` | `boolean` | `false` | Cache deserialized values in JS (avoids repeated native reads) |
203+
| `coalesceDiskWrites` | `boolean` | `false` | Batch same-tick Disk writes per key until `flushDiskWrites()` |
199204
| `coalesceSecureWrites` | `boolean` | `false` | Batch same-tick Secure writes per key |
200205
| `namespace` | `string` || Prefix key as `namespace:key` for isolation |
201206
| `biometric` | `boolean` | `false` | Require biometric auth (Secure scope only) |
@@ -278,7 +283,10 @@ import { storage, StorageScope } from "react-native-nitro-storage";
278283
| `storage.getByPrefix(prefix, scope)` | Get raw key-value pairs for keys matching a prefix |
279284
| `storage.getAll(scope)` | Get all key-value pairs as `Record<string, string>` |
280285
| `storage.size(scope)` | Number of stored keys |
286+
| `storage.getCapabilities()` | Read runtime backend metadata and buffering support |
281287
| `storage.setAccessControl(level)` | Set default secure access control for subsequent secure writes (native only) |
288+
| `storage.setDiskWritesAsync(enabled)` | Buffer raw Disk writes in JS until flushed (all platforms) |
289+
| `storage.flushDiskWrites()` | Force flush queued Disk writes from raw APIs / coalesced items |
282290
| `storage.setSecureWritesAsync(enabled)` | Toggle async secure writes on Android (`false` by default) |
283291
| `storage.flushSecureWrites()` | Force flush of queued secure writes when coalescing is enabled |
284292
| `storage.setKeychainAccessGroup(group)` | Set keychain access group for app sharing (native only) |
@@ -290,6 +298,14 @@ import { storage, StorageScope } from "react-native-nitro-storage";
290298
| `storage.getMetricsSnapshot()` | Get aggregate counters/latency stats keyed by operation |
291299
| `storage.resetMetrics()` | Reset in-memory metrics counters |
292300

301+
| Web helper | Description |
302+
| -------------------------------------- | -------------------------------------------------------------------- |
303+
| `setWebDiskStorageBackend(backend?)` | Override the web Disk backend (web only) |
304+
| `getWebDiskStorageBackend()` | Read the active web Disk backend (web only) |
305+
| `setWebSecureStorageBackend(backend?)` | Override the web Secure backend (web only) |
306+
| `getWebSecureStorageBackend()` | Read the active web Secure backend (web only) |
307+
| `flushWebStorageBackends()` | Await optional backend durability hooks for Disk + Secure (web only) |
308+
293309
> `storage.getAll(StorageScope.Secure)` returns regular secure entries. Biometric-protected values are not included in this snapshot API.
294310
295311
#### Global utility examples
@@ -307,6 +323,7 @@ storage.getKeysByPrefix("user-42:", StorageScope.Disk);
307323
storage.getByPrefix("user-42:", StorageScope.Disk);
308324
storage.getAll(StorageScope.Disk);
309325
storage.size(StorageScope.Disk);
326+
storage.getCapabilities();
310327

311328
storage.clearNamespace("user-42", StorageScope.Disk);
312329
storage.clearBiometric();
@@ -318,6 +335,32 @@ storage.clear(StorageScope.Memory);
318335
storage.clearAll();
319336
```
320337

338+
#### Disk write buffering
339+
340+
Disk writes can now be buffered in JS, similar to secure write coalescing, which is useful when you are doing bursty persistence and want an explicit durability boundary.
341+
342+
```ts
343+
import {
344+
createStorageItem,
345+
storage,
346+
StorageScope,
347+
} from "react-native-nitro-storage";
348+
349+
const bufferedDraft = createStorageItem({
350+
key: "draft",
351+
scope: StorageScope.Disk,
352+
defaultValue: "",
353+
coalesceDiskWrites: true,
354+
});
355+
356+
bufferedDraft.set("hello");
357+
storage.setDiskWritesAsync(true);
358+
storage.setString("draft:raw", "value", StorageScope.Disk);
359+
360+
storage.flushDiskWrites(); // commit queued Disk writes
361+
storage.setDiskWritesAsync(false);
362+
```
363+
321364
#### Android secure write mode
322365

323366
`storage.setSecureWritesAsync(true)` switches secure writes from synchronous `commit()` to asynchronous `apply()` on Android.
@@ -335,25 +378,63 @@ storage.setSecureWritesAsync(true);
335378
storage.flushSecureWrites(); // deterministic durability boundary
336379
```
337380

338-
#### Custom web secure backend
381+
#### Custom Web Disk and Secure Backends
382+
383+
By default, web Disk and Secure scopes use `localStorage`. Disk excludes Nitro's secure prefixes, and Secure stores under `__secure_` / `__bio_` prefixes.
339384

340-
By default, web Secure scope uses `localStorage` with `__secure_` key prefixing. You can replace it with a custom backend (for example encrypted IndexedDB adapter).
385+
You can replace either backend with a custom implementation. The minimal backend contract is:
386+
387+
```ts
388+
type WebStorageBackend = {
389+
getItem(key: string): string | null;
390+
setItem(key: string, value: string): void;
391+
removeItem(key: string): void;
392+
clear(): void;
393+
getAllKeys(): string[];
394+
getMany?: (keys: string[]) => (string | null)[];
395+
setMany?: (entries: ReadonlyArray<readonly [string, string]>) => void;
396+
removeMany?: (keys: string[]) => void;
397+
size?: () => number;
398+
subscribe?: (
399+
listener: (event: { key: string | null; newValue: string | null }) => void,
400+
) => () => void;
401+
flush?: () => Promise<void>;
402+
name?: string;
403+
};
404+
```
405+
406+
Optional hooks are used for faster batch operations, custom cross-tab sync, and explicit durability boundaries.
341407

342408
```ts
343409
import {
410+
flushWebStorageBackends,
411+
getWebDiskStorageBackend,
344412
getWebSecureStorageBackend,
413+
setWebDiskStorageBackend,
345414
setWebSecureStorageBackend,
346415
} from "react-native-nitro-storage";
347416

417+
setWebDiskStorageBackend({
418+
getItem: (key) => diskStore.get(key) ?? null,
419+
setItem: (key, value) => diskStore.set(key, value),
420+
removeItem: (key) => diskStore.delete(key),
421+
clear: () => diskStore.clear(),
422+
getAllKeys: () => Array.from(diskStore.keys()),
423+
});
424+
348425
setWebSecureStorageBackend({
349426
getItem: (key) => encryptedStore.get(key) ?? null,
350427
setItem: (key, value) => encryptedStore.set(key, value),
351428
removeItem: (key) => encryptedStore.delete(key),
352429
clear: () => encryptedStore.clear(),
353-
getAllKeys: () => encryptedStore.keys(),
430+
getAllKeys: () => Array.from(encryptedStore.keys()),
354431
});
355432

433+
await flushWebStorageBackends();
434+
435+
const diskBackend = getWebDiskStorageBackend();
356436
const backend = getWebSecureStorageBackend();
437+
console.log("custom disk backend active:", diskBackend !== undefined);
357438
console.log("custom backend active:", backend !== undefined);
358439
```
359440

@@ -376,12 +457,24 @@ setWebSecureStorageBackend(backend);
376457

377458
- **Async init**: `createIndexedDBBackend()` opens (or creates) the IndexedDB database and hydrates an in-memory cache from all stored entries before resolving.
378459
- **Synchronous reads**: all `getItem` calls are served from the in-memory cache — no async overhead after init.
379-
- **Fire-and-forget writes**: `setItem`, `removeItem`, and `clear` update the cache synchronously, then persist to IndexedDB in the background. The cache is always the authoritative source.
460+
- **Queued writes + durability**: writes update the cache synchronously, persist in the background, and can be awaited via `await backend.flush()` or `await flushWebStorageBackends()`.
461+
- **Cross-tab sync**: backend instances on the same `dbName`/`storeName` coordinate through `BroadcastChannel` so cache invalidation reaches other tabs.
380462
- **Custom database/store**: optionally pass `dbName` and `storeName` to isolate databases per environment or tenant.
381463

382464
```ts
383465
const backend = await createIndexedDBBackend("my-app-db", "secure-kv");
384466
setWebSecureStorageBackend(backend);
467+
await backend.flush?.();
468+
```
469+
470+
You can also pass an optional third argument to receive async persistence failures:
471+
472+
```ts
473+
const backend = await createIndexedDBBackend("my-app-db", "secure-kv", {
474+
onError: (error) => {
475+
console.error("indexeddb persistence failed", error);
476+
},
477+
});
385478
```
386479

387480
---
@@ -530,16 +623,23 @@ These are synchronous and go directly to the native backend without any serializ
530623

531624
---
532625

533-
### `isKeychainLockedError(err)`
626+
### Error Classification
534627

535-
Utility to detect iOS Keychain locked errors and Android key invalidation errors in secure storage operations. Returns `true` if the error was caused by a locked keychain (device locked, first unlock not yet performed, etc.) or an Android `KeyPermanentlyInvalidatedException` / `InvalidKeyException`. Always returns `false` on web.
628+
`getStorageErrorCode(err)` returns a stable classification for common native/web storage failures. Native bridges now emit stable `[nitro-error:<code>]` tags so the classification path does not depend on platform exception wording alone.
629+
`isKeychainLockedError(err)` remains the convenience helper for retry-after-unlock flows and now delegates to the structured code path.
536630

537631
```ts
538-
import { isKeychainLockedError } from "react-native-nitro-storage";
632+
import {
633+
getStorageErrorCode,
634+
isKeychainLockedError,
635+
} from "react-native-nitro-storage";
539636

540637
try {
541638
secureItem.get();
542639
} catch (err) {
640+
const code = getStorageErrorCode(err);
641+
// "keychain_locked" | "authentication_required" | ...
642+
543643
if (isKeychainLockedError(err)) {
544644
// device is locked — retry after unlock
545645
}

apps/example/app/index.tsx

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
createSecureAuthStorage,
55
createStorageItem,
66
getBatch,
7+
getStorageErrorCode,
78
migrateToLatest,
89
registerMigration,
910
removeBatch,
@@ -137,6 +138,13 @@ const batch3 = createStorageItem({
137138
defaultValue: "-",
138139
});
139140

141+
const bufferedDiskItem = createStorageItem({
142+
key: "disk-buffered-item",
143+
scope: StorageScope.Disk,
144+
defaultValue: "",
145+
coalesceDiskWrites: true,
146+
});
147+
140148
// ─── Component ────────────────────────────────────────────────────────────────
141149

142150
const HOOK_LABELS = ["initial", "alpha", "beta", "gamma", "delta"];
@@ -222,6 +230,10 @@ export default function HomeScreen() {
222230
// 17. Prefix & Keys
223231
const [prefixKeys, setPrefixKeys] = useState<string[]>([]);
224232
const [allMemoryKeys, setAllMemoryKeys] = useState<string[]>([]);
233+
const [diskBufferingEnabled, setDiskBufferingEnabled] = useState(false);
234+
const [diskBufferedPreview, setDiskBufferedPreview] = useState("(empty)");
235+
const [classifiedErrorCode, setClassifiedErrorCode] = useState("(none)");
236+
const capabilities = storage.getCapabilities();
225237

226238
return (
227239
<Page title="Nitro Storage" subtitle="Complete feature showcase">
@@ -1002,6 +1014,123 @@ export default function HomeScreen() {
10021014
/>
10031015
</Card>
10041016

1017+
<Card
1018+
title="Runtime Capabilities"
1019+
subtitle="Backend metadata and structured error codes"
1020+
indicatorColor={Colors.secure}
1021+
>
1022+
<StatusRow
1023+
testID="capabilities-platform"
1024+
label="Platform"
1025+
value={capabilities.platform}
1026+
/>
1027+
<StatusRow
1028+
testID="capabilities-disk-backend"
1029+
label="Disk backend"
1030+
value={capabilities.backend.disk}
1031+
/>
1032+
<StatusRow
1033+
testID="capabilities-secure-backend"
1034+
label="Secure backend"
1035+
value={capabilities.backend.secure}
1036+
/>
1037+
<StatusRow
1038+
testID="capabilities-buffering"
1039+
label="Write buffering"
1040+
value={`disk=${String(capabilities.writeBuffering.disk)} secure=${String(capabilities.writeBuffering.secure)}`}
1041+
/>
1042+
<StatusRow
1043+
testID="capabilities-error-code"
1044+
label="Sample error code"
1045+
value={classifiedErrorCode}
1046+
/>
1047+
<Button
1048+
testID="classify-storage-error"
1049+
title="Classify Tagged Error"
1050+
onPress={() => {
1051+
setClassifiedErrorCode(
1052+
getStorageErrorCode(
1053+
new Error(
1054+
"[nitro-error:authentication_required] NitroStorage: auth required",
1055+
),
1056+
) ?? "(none)",
1057+
);
1058+
}}
1059+
size="sm"
1060+
/>
1061+
</Card>
1062+
1063+
<Card
1064+
title="Disk Write Buffering"
1065+
subtitle="Buffered disk writes with explicit flush"
1066+
indicatorColor={Colors.disk}
1067+
>
1068+
<StatusRow
1069+
testID="disk-buffer-enabled"
1070+
label="Global buffering"
1071+
value={diskBufferingEnabled ? "enabled" : "disabled"}
1072+
/>
1073+
<StatusRow
1074+
testID="disk-buffer-preview"
1075+
label="Buffered preview"
1076+
value={diskBufferedPreview}
1077+
/>
1078+
<View style={styles.row}>
1079+
<Button
1080+
testID="disk-buffer-toggle"
1081+
title={diskBufferingEnabled ? "Disable" : "Enable"}
1082+
onPress={() => {
1083+
const next = !diskBufferingEnabled;
1084+
storage.setDiskWritesAsync(next);
1085+
setDiskBufferingEnabled(next);
1086+
}}
1087+
style={styles.flex1}
1088+
/>
1089+
<Button
1090+
testID="disk-buffer-raw"
1091+
title="Queue Raw"
1092+
variant="secondary"
1093+
onPress={() => {
1094+
storage.setString(
1095+
"disk-buffer-raw",
1096+
"buffered-raw",
1097+
StorageScope.Disk,
1098+
);
1099+
setDiskBufferedPreview(
1100+
storage.getString("disk-buffer-raw", StorageScope.Disk) ??
1101+
"(empty)",
1102+
);
1103+
}}
1104+
style={styles.flex1}
1105+
/>
1106+
</View>
1107+
<View style={styles.row}>
1108+
<Button
1109+
testID="disk-buffer-item"
1110+
title="Queue Item"
1111+
variant="secondary"
1112+
onPress={() => {
1113+
bufferedDiskItem.set("buffered-item");
1114+
setDiskBufferedPreview(bufferedDiskItem.get() || "(empty)");
1115+
}}
1116+
style={styles.flex1}
1117+
/>
1118+
<Button
1119+
testID="disk-buffer-flush"
1120+
title="Flush"
1121+
onPress={() => {
1122+
storage.flushDiskWrites();
1123+
setDiskBufferedPreview(
1124+
storage.getString("disk-buffer-raw", StorageScope.Disk) ??
1125+
bufferedDiskItem.get() ??
1126+
"(empty)",
1127+
);
1128+
}}
1129+
style={styles.flex1}
1130+
/>
1131+
</View>
1132+
</Card>
1133+
10051134
{/* Smoke Test Runner */}
10061135
<SmokeTestRunner />
10071136
</Page>

0 commit comments

Comments
 (0)