@chipmobilesdk/rn-local-db 0.1.2 → 0.2.1

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 (84) hide show
  1. package/CHANGELOG.md +122 -52
  2. package/README.md +663 -661
  3. package/package.json +59 -62
  4. package/skills/sdk-rn-local-db/SKILL.md +0 -1
  5. package/src/adapter/budgets.ts +67 -67
  6. package/src/adapter/collection.ts +303 -298
  7. package/src/adapter/deletion.ts +252 -238
  8. package/src/adapter/index.ts +64 -68
  9. package/src/adapter/lifecycle.ts +517 -444
  10. package/src/adapter/transaction.ts +71 -65
  11. package/src/artifacts/availability.ts +67 -69
  12. package/src/artifacts/cascade.ts +114 -78
  13. package/src/artifacts/paths.ts +29 -29
  14. package/src/artifacts/reconcile.ts +63 -63
  15. package/src/artifacts/store.ts +334 -323
  16. package/src/contract/adapter.ts +76 -78
  17. package/src/contract/attachment.ts +75 -72
  18. package/src/contract/backup.ts +28 -28
  19. package/src/contract/config.ts +54 -54
  20. package/src/contract/encryption.ts +34 -34
  21. package/src/contract/errors.ts +76 -76
  22. package/src/contract/migration.ts +42 -40
  23. package/src/contract/query.ts +81 -81
  24. package/src/contract/record.ts +46 -42
  25. package/src/contract/schema.ts +36 -36
  26. package/src/contract/scope.ts +36 -36
  27. package/src/contract/transaction.ts +52 -46
  28. package/src/crypto/chunked-cipher.ts +153 -144
  29. package/src/crypto/derive.ts +64 -63
  30. package/src/crypto/key-provider.ts +78 -78
  31. package/src/crypto/provider.ts +81 -81
  32. package/src/diagnostics/dev-log.ts +112 -108
  33. package/src/diagnostics/errors.ts +62 -66
  34. package/src/diagnostics/faults.ts +58 -64
  35. package/src/diagnostics/map-native.ts +108 -106
  36. package/src/diagnostics/redact.ts +137 -141
  37. package/src/index.ts +80 -110
  38. package/src/migration/executor.ts +132 -136
  39. package/src/migration/ledger.ts +95 -97
  40. package/src/migration/validate-chain.ts +65 -65
  41. package/src/platform/backup-android-verify.ts +88 -88
  42. package/src/platform/backup-ios.ts +48 -48
  43. package/src/platform/bytes.ts +144 -0
  44. package/src/platform/fs.ts +89 -89
  45. package/src/platform/location.ts +135 -135
  46. package/src/query/budgets.ts +54 -57
  47. package/src/query/compare.ts +91 -91
  48. package/src/query/cursor.ts +100 -99
  49. package/src/query/filter.ts +82 -82
  50. package/src/query/paginate.ts +51 -51
  51. package/src/query/sort.ts +55 -56
  52. package/src/query/validate.ts +171 -171
  53. package/src/schema/declare.ts +42 -45
  54. package/src/schema/validate-config.ts +158 -158
  55. package/src/scope/hash.ts +131 -134
  56. package/src/scope/normalize.ts +83 -90
  57. package/src/scope/validate.ts +83 -83
  58. package/src/sqlite/adapter.ts +26 -26
  59. package/src/sqlite/batch.ts +242 -232
  60. package/src/sqlite/catalog.ts +105 -108
  61. package/src/sqlite/connection.ts +227 -205
  62. package/src/sqlite/index-coverage.ts +54 -53
  63. package/src/sqlite/native.ts +83 -83
  64. package/src/sqlite/row-codec.ts +217 -201
  65. package/src/sqlite/sql-builder.ts +244 -244
  66. package/src/sqlite/transaction-queue.ts +87 -0
  67. package/src/sqlite/transactions.ts +33 -33
  68. package/src/sqlite/values.ts +24 -24
  69. package/src/testing/conformance/attachments.ts +138 -136
  70. package/src/testing/conformance/encryption.ts +150 -150
  71. package/src/testing/conformance/index.ts +161 -161
  72. package/src/testing/conformance/lifecycle.ts +99 -99
  73. package/src/testing/conformance/migrations.ts +177 -177
  74. package/src/testing/conformance/queries.ts +204 -167
  75. package/src/testing/conformance/records.ts +169 -169
  76. package/src/testing/conformance/scopes.ts +86 -88
  77. package/src/testing/conformance/transactions.ts +149 -149
  78. package/src/testing/faults.ts +21 -21
  79. package/src/testing/index.ts +17 -21
  80. package/src/testing/memory-adapter/collection.ts +214 -219
  81. package/src/testing/memory-adapter/index.ts +510 -388
  82. package/src/testing/memory-adapter/migrations.ts +117 -119
  83. package/src/testing/memory-adapter/reset.ts +50 -50
  84. package/src/testing/memory-adapter/store.ts +128 -134
package/README.md CHANGED
@@ -1,381 +1,383 @@
1
- # @chipmobilesdk/rn-local-db
2
-
1
+ # @chipmobilesdk/rn-local-db
2
+
3
3
  Offline-first, scope-isolated local storage for ChipMobileSdk apps.
4
4
 
5
5
  > **License:** `UNLICENSED`. Published publicly for use by the owner's
6
6
  > applications; no open-source license is granted.
7
7
 
8
- - **Version**: `0.1.0`
8
+ - **Version**: see [CHANGELOG](CHANGELOG.md)
9
9
  - **Entrypoints**: `@chipmobilesdk/rn-local-db` and the test-only
10
10
  `@chipmobilesdk/rn-local-db/testing`
11
-
12
- Structured records, queries a list screen can actually use, transactions with real rollback, versioned migrations that fail loudly, an explicit deletion lifecycle, package-owned file attachments, opt-in encryption at rest, and typed diagnostics that never carry the user's data.
13
-
14
- **Nothing leaves the device.** The package makes no network request, holds no business rules, declares no collections of its own, and depends on no sibling package.
15
-
16
- ---
17
-
18
- ## Contents
19
-
20
- - [Install](#install)
21
- - [The scope model](#the-scope-model)
22
- - [Declaring a schema](#declaring-a-schema)
23
- - [Records and the four value conditions](#records-and-the-four-value-conditions)
24
- - [Queries](#queries)
25
- - [Transactions and batches](#transactions-and-batches)
26
- - [Migrations](#migrations)
27
- - [Deletion, and why sign-out is not deletion](#deletion-and-why-sign-out-is-not-deletion)
28
- - [Attachments](#attachments)
29
- - [Encryption](#encryption)
30
- - [Backup posture](#backup-posture)
11
+
12
+ Structured records, queries a list screen can actually use, transactions with real rollback, versioned migrations that fail loudly, an explicit deletion lifecycle, package-owned file attachments, opt-in encryption at rest, and typed diagnostics that never carry the user's data.
13
+
14
+ **Nothing leaves the device.** The package makes no network request, holds no business rules, declares no collections of its own, and depends on no sibling package.
15
+
16
+ ---
17
+
18
+ ## Contents
19
+
20
+ - [Install](#install)
21
+ - [The scope model](#the-scope-model)
22
+ - [Declaring a schema](#declaring-a-schema)
23
+ - [Records and the four value conditions](#records-and-the-four-value-conditions)
24
+ - [Queries](#queries)
25
+ - [Transactions and batches](#transactions-and-batches)
26
+ - [Migrations](#migrations)
27
+ - [Deletion, and why sign-out is not deletion](#deletion-and-why-sign-out-is-not-deletion)
28
+ - [Attachments](#attachments)
29
+ - [Encryption](#encryption)
30
+ - [Backup posture](#backup-posture)
31
31
  - [Diagnostics](#diagnostics)
32
32
  - [Public API](#public-api)
33
33
  - [Testing against the in-memory adapter](#testing-against-the-in-memory-adapter)
34
- - [Budgets](#budgets)
35
- - [Compatibility](#compatibility)
36
- - [What this package stores, and where](#what-this-package-stores-and-where)
37
-
38
- ---
39
-
40
- ## Install
41
-
42
- ```sh
43
- npm install @chipmobilesdk/rn-local-db
44
- npm install @op-engineering/op-sqlite @dr.pogodin/react-native-fs
45
- # Only if you will enable encryption:
46
- npm install react-native-quick-crypto
47
- cd ios && pod install
48
- ```
49
-
50
- ### Enable SQLCipher (only if you will encrypt)
51
-
52
- SQLCipher is a **build flag in your app**, not a separate package. Add it to your app's `package.json`:
53
-
54
- ```json
55
- {
56
- "op-sqlite": {
57
- "sqlcipher": true
58
- }
59
- }
60
- ```
61
-
62
- Then rebuild. Without the flag, `openDatabase` with `encryption.enabled` fails with `ENCRYPTION_UNAVAILABLE` rather than writing plaintext into a file you believe is encrypted.
63
-
64
- ### Android backup exclusion (required)
65
-
66
- Android reads backup participation from your manifest, which is build-time and app-owned. This package authors no native code, so it cannot contribute the entry — it verifies coverage at runtime and reports the observed posture instead. See [Backup posture](#backup-posture) for the exact XML.
67
-
68
- ### Remove the inherited storage permission (recommended)
69
-
70
- `@dr.pogodin/react-native-fs` declares `WRITE_EXTERNAL_STORAGE` unconditionally in its own manifest, so it merges into your app whether or not you use it. **This package never writes outside the app-private documents directory** — every filesystem call is confined to `chipmobilesdk-localdb/` — so unless something else in your app needs it, the permission is declared and unused. That shows up in your Play listing and invites a data-safety question with no true answer.
71
-
72
- ```xml
73
- <!-- android/app/src/main/AndroidManifest.xml -->
74
- <manifest xmlns:android="http://schemas.android.com/apk/res/android"
75
- xmlns:tools="http://schemas.android.com/tools">
76
-
77
- <uses-permission
78
- android:name="android.permission.WRITE_EXTERNAL_STORAGE"
79
- tools:node="remove" />
80
- ```
81
-
82
- `rn-local-db` itself requires **no permissions on either platform**.
83
-
84
- ---
85
-
86
- ## The scope model
87
-
88
- A **scope** is the isolation boundary for all data. Every read and write happens inside exactly one, and a handle's scope is fixed when you open it.
89
-
90
- There is no `switchScope`, and **no read, write, or query operation takes a scope argument.** That absence is the guarantee: a cross-scope leak is not something you are prevented from writing, it is something you cannot express.
91
-
92
- ```ts
93
- import { openDatabase } from '@chipmobilesdk/rn-local-db';
94
-
95
- const guest = await openDatabase({ ...config, scope: { kind: 'guest' } });
96
- const alice = await openDatabase({
97
- ...config,
98
- scope: { kind: 'identity', id: 'alice@example.com' },
99
- });
100
- const aliceAtAcme = await openDatabase({
101
- ...config,
102
- scope: { kind: 'identity', id: 'alice@example.com', tenant: 'acme' },
103
- });
104
- ```
105
-
106
- Those are three different scopes holding three separate sets of data. The same record id can exist in all three, and neither overwrites the others.
107
-
108
- **Scope components are treated as sensitive.** They are normalized, joined canonically, and hashed; only the SHA-256 digest reaches disk, an error, or a log. A directory named after an email address would be readable even when the database inside it is encrypted, and would travel in any crash report that includes a file path.
109
-
110
- ```ts
111
- alice.scopeKey; // "9f2c…" — the digest, never "alice@example.com"
112
- ```
113
-
114
- ### Worked example: an account switcher
115
-
116
- ```ts
117
- type Session = { handle: DatabaseHandle; scope: ScopeDescriptor };
118
-
119
- const sessions = new Map<string, Session>();
120
-
121
- async function signIn(accountId: string): Promise<Session> {
122
- const scope = { kind: 'identity', id: accountId } as const;
123
- const handle = await openDatabase({ ...config, scope });
124
- const session = { handle, scope };
125
- sessions.set(accountId, session);
126
- return session;
127
- }
128
-
129
- async function signOut(accountId: string): Promise<void> {
130
- // Closes the handle. Deletes nothing — see the deletion section.
131
- await sessions.get(accountId)?.handle.close();
132
- sessions.delete(accountId);
133
- }
134
-
135
- async function forgetMe(accountId: string): Promise<void> {
136
- await signOut(accountId);
137
- const outcome = await deleteScopeData({
138
- name: config.name,
139
- scope: { kind: 'identity', id: accountId },
140
- });
141
- console.log(`removed ${outcome.removedRecords} records`);
142
- }
143
- ```
144
-
145
- Up to eight handles may be open at once, so keeping a previous account warm while the user switches is a supported arrangement rather than a leak.
146
-
147
- ---
148
-
149
- ## Declaring a schema
150
-
151
- Declaring a field makes it **queryable**. It does not restrict what a record may contain: undeclared fields are stored and returned faithfully, they simply cannot be filtered or sorted on.
152
-
153
- ```ts
154
- const config: DatabaseConfig = {
155
- name: 'my_app',
156
- scope: { kind: 'guest' },
157
- schemaVersion: 2,
158
- collections: [
159
- {
160
- name: 'notes',
161
- fields: [
162
- { name: 'title', type: 'string' },
163
- { name: 'body', type: 'string', nullable: true },
164
- { name: 'score', type: 'number' },
165
- { name: 'pinned', type: 'boolean' },
166
- { name: 'updated', type: 'timestamp' },
167
- ],
168
- indexes: [
169
- { name: 'notes_by_score', fields: ['score'] },
170
- { name: 'notes_by_pinned_score', fields: ['pinned', 'score'] },
171
- ],
172
- softDelete: true, // opt-in
173
- timestamps: true, // opt-in: the package maintains createdAt/updatedAt
174
- },
175
- ],
176
- migrations: [/* see below */],
177
- };
178
- ```
179
-
180
- Validation runs to completion **before any write**. A duplicate collection name, an index over an undeclared field, or a broken migration chain each raise before a file is touched.
181
-
182
- ---
183
-
184
- ## Records and the four value conditions
185
-
186
- ```ts
187
- const notes = handle.collection<Note>('notes');
188
-
189
- await notes.insert({ id: 'n1', data: { title: 'First', score: 1, pinned: false } });
190
- await notes.update('n1', { data: { title: 'Renamed', score: 1, pinned: false } });
191
- const outcome = await notes.upsert({ id: 'n1', data: { …} });
192
- outcome.operation; // 'created' | 'updated' — it tells you which it was
193
-
194
- await notes.get('n1'); // rejects with RECORD_NOT_FOUND when absent
195
- await notes.find('n1'); // returns null when absent
196
-
197
- await notes.delete('n1'); // hard
198
- await notes.softDelete('n1'); // requires softDelete: true
199
- await notes.restore('n1');
200
- ```
201
-
202
- `get` and `find` both exist because these four states are four different answers, and an app that cannot tell them apart writes bugs that look like data loss:
203
-
204
- | Condition | How you see it |
205
- |---|---|
206
- | The record does not exist | `find` returns `null`; `get` rejects with `RECORD_NOT_FOUND` |
207
- | The field is present and null | `'body' in record.data` is `true`, `record.data.body` is `null` |
208
- | The field is absent | `'body' in record.data` is `false` |
209
- | The record is soft-deleted | Excluded from reads unless `includeSoftDeleted: true` |
210
-
211
- These survive a write-and-read cycle because reads reconstruct the record from the stored payload alone. Nothing is coerced: numbers keep their precision, booleans stay booleans, nested structures come back identically.
212
-
213
- Mutating the object you passed in does not change what was stored, and mutating what you got back does not change the store.
214
-
215
- ---
216
-
217
- ## Queries
218
-
219
- ```ts
220
- const page = await notes.list({
221
- filter: {
222
- op: 'and',
223
- nodes: [
224
- { op: 'gte', field: 'score', value: 80 },
225
- { op: 'eq', field: 'pinned', value: true },
226
- ],
227
- },
228
- sort: [{ field: 'score', direction: 'desc' }],
229
- page: { size: 25, cursor: previousPage?.cursor },
230
- });
231
-
232
- page.records; // StoredRecord<Note>[]
233
- page.hasMore; // whether another page exists
234
- page.cursor; // opaque; absent on the final page
235
-
236
- const total = await notes.count(filter); // agrees with the list, always
237
- ```
238
-
239
- **Operators**: `eq`, `ne`, `lt`, `lte`, `gt`, `gte`, `in`, `nin`, `between`, `isNull`, `isNotNull`, `exists`, combined with `and`, `or`, `not`.
240
-
241
- `exists` is the one that answers a question about *presence*, which is what keeps "absent" distinguishable from "null". An absent field satisfies no other operator — not even `ne`.
242
-
243
- **Paging is keyset-based**, anchored to the last row's sort values and the record id. Page 2,000 costs the same as page 1, and a concurrent write cannot make a record appear twice or vanish. `OFFSET` has neither property. The record id is always appended as the final sort key, so ties break deterministically.
244
-
245
- **An invalid query is an error, never a result.** A filter on an undeclared field raises `QUERY_INVALID` rather than returning everything or nothing — both of which look like data and neither of which is.
246
-
247
- ```ts
248
- // Development-mode diagnostic: is this query actually using the index?
249
- const report = await handle.explain('notes', query);
250
- report.usesIndex; // answered by the engine's plan, not by reading declarations
251
- ```
252
-
253
- ---
254
-
255
- ## Transactions and batches
256
-
257
- ```ts
258
- await handle.transaction(async tx => {
259
- await tx.collection('orders').insert({ id: 'o1', data: order });
260
- await tx.collection('lines').insert({ id: 'l1', data: line });
261
- // Throw, or call tx.abort(), and neither write survives.
262
- });
263
- ```
264
-
265
- Reads inside a transaction see that transaction's own writes; reads outside do not until it commits. A settled transaction rejects further use with `TRANSACTION_STATE`, so cleanup code cannot accidentally write outside the rollback it was meant to be part of.
266
-
267
- A transaction never spans two scopes — with one database file per scope, that is a type error rather than a runtime check.
268
-
269
- ```ts
270
- const outcome = await handle.batch([
271
- { type: 'upsert', collection: 'notes', record: { id: 'a', data } },
272
- { type: 'softDelete', collection: 'notes', id: 'b' },
273
- ]);
274
- outcome.applied; // number
275
- outcome.outcomes; // one WriteOutcome per operation
276
- ```
277
-
278
- A batch is one atomic unit, up to 1,000 operations. Every operation is resolved before any of them runs, so a conflict is reported with the database untouched.
279
-
280
- ---
281
-
282
- ## Migrations
283
-
284
- ```ts
285
- const migrations: Migration[] = [
286
- { version: 1, migrate: async () => {} },
287
- {
288
- version: 2,
289
- migrate: async ctx => {
290
- const page = await ctx.collection('notes').list({ page: { size: 500 } });
291
- for (const record of page.records) {
292
- await ctx.collection('notes').upsert({
293
- id: record.id,
294
- data: { ...record.data, pinned: false },
295
- });
296
- }
297
- },
298
- },
299
- ];
300
- ```
301
-
302
- Rules, each of which exists because the alternative loses data:
303
-
304
- - **Ascending, pending only, at most once per scope.** The ledger is per scope, so an account created after a migration shipped runs the whole chain on first open rather than being assumed current.
305
- - **One transaction per migration.** The recorded version advances only on success, inside the same transaction as the migration's own writes.
306
- - **A failure returns no handle at all.** Not a degraded handle, not a read-only one — nothing. `MIGRATION_FAILED` carries `recordedVersion`, `failingVersion`, and `outcome` so you can tell a transient failure from a permanent one.
307
- - **A retry starts from the same version.** Ten consecutive failed opens alter zero records.
308
- - **A chain that does not reach `schemaVersion`, has a gap, or repeats a version is refused before anything runs**, with `MIGRATION_CONFIG_INVALID`.
309
- - **Opening a database written by a newer schema raises `SCHEMA_DOWNGRADE`** and truncates nothing. A user who rolls back a build gets their data again when they roll forward.
310
-
311
- ### Testing a migration
312
-
313
- ```ts
314
- import { createMemoryAdapter } from '@chipmobilesdk/rn-local-db/testing';
315
-
316
- const adapter = createMemoryAdapter();
317
- const v1 = await adapter.open({ ...config, schemaVersion: 1, migrations: [migrations[0]] });
318
- await v1.collection('notes').insert({ id: 'n1', data: legacyShape });
319
- await v1.close();
320
-
321
- const v2 = await adapter.open({ ...config, schemaVersion: 2, migrations });
322
- expect((await v2.collection('notes').get('n1')).data.pinned).toBe(false);
323
- ```
324
-
325
- ### Recovering from a broken migration
326
-
327
- `resetScope` discards one scope's data and leaves it ready for a clean first run. It is **separately named and never reachable from `openDatabase`** on purpose: an automatic fallback would turn a bad migration shipped to production into silent data loss for every user who launched the app.
328
-
329
- ```ts
330
- const outcome = await resetScope({ name: config.name, scope });
331
- outcome.removedRecords; // it tells you what it destroyed
332
- ```
333
-
334
- ---
335
-
336
- ## Deletion, and why sign-out is not deletion
337
-
338
- **The package never deletes anything unasked.** No expiry, no cache eviction, no sweep on app upgrade, nothing on `close()`. Closing a handle is what a sign-out looks like to this package, and it does nothing to the data.
339
-
340
- ```ts
341
- // Sign-out: the data survives, and is there when the user signs back in.
342
- await handle.close();
343
-
344
- // A deletion request: one scope, every collection, and nothing else.
345
- const outcome = await deleteScopeData({ name: config.name, scope });
346
-
347
- // Uninstall-equivalent: the whole database directory.
348
- await deleteDatabase({ name: config.name });
349
- ```
350
-
351
- These are module-level functions rather than handle methods because they must work whether or not a handle is open on the target.
352
-
353
- Every one returns a `DeletionOutcome` rather than a bare success — what was removed, what was orphaned, and what could not be removed. A file the package could not delete is still on disk holding the user's data, and an app that reported "deleted" would have said something untrue.
354
-
355
- If a deletion is interrupted — a force-quit, a battery death — the next open reports `SCOPE_DELETED` rather than presenting the scope as healthy. Call `deleteScopeData` again to finish it.
356
-
357
- A handle whose scope is deleted beneath it reports `SCOPE_DELETED` on its next use. The deletion does not wait for it, and nothing revives it: open a new handle and rebuild whatever was built on the old one. An app that retries through the dead handle gets the same error every time, which reaches the user as a screen that only relaunching can fix.
358
-
359
- ---
360
-
361
- ## Attachments
362
-
363
- The package owns both halves: the file on disk and the reference to it.
364
-
365
- ```ts
366
- const attachments = handle.attachments();
367
-
368
- const reference = await attachments.write({
369
- id: 'photo-1',
370
- owner: { collection: 'notes', id: 'n1' },
371
- contentType: 'image/jpeg',
372
- content: base64,
373
- });
374
-
375
- await attachments.read('photo-1'); // whole
376
- await attachments.read('photo-1', { offset: 0, length: 4096 }); // a range
377
- await attachments.availability('photo-1'); // never opens the file
378
- await attachments.listFor({ collection: 'notes', id: 'n1' });
34
+ - [Budgets](#budgets)
35
+ - [Compatibility](#compatibility)
36
+ - [What this package stores, and where](#what-this-package-stores-and-where)
37
+
38
+ ---
39
+
40
+ ## Install
41
+
42
+ ```sh
43
+ npm install @chipmobilesdk/rn-local-db
44
+ npm install @op-engineering/op-sqlite @dr.pogodin/react-native-fs
45
+ # Only if you will enable encryption (quick-base64 is quick-crypto's native peer):
46
+ npm install react-native-quick-crypto react-native-quick-base64
47
+ cd ios && pod install
48
+ ```
49
+
50
+ ### Enable SQLCipher (only if you will encrypt)
51
+
52
+ SQLCipher is a **build flag in your app**, not a separate package. Add it to your app's `package.json`:
53
+
54
+ ```json
55
+ {
56
+ "op-sqlite": {
57
+ "sqlcipher": true
58
+ }
59
+ }
60
+ ```
61
+
62
+ Then rebuild. Without the flag, `openDatabase` with `encryption.enabled` fails with `ENCRYPTION_UNAVAILABLE` rather than writing plaintext into a file you believe is encrypted.
63
+
64
+ ### Android backup exclusion (required)
65
+
66
+ Android reads backup participation from your manifest, which is build-time and app-owned. This package authors no native code, so it cannot contribute the entry — it verifies coverage at runtime and reports the observed posture instead. See [Backup posture](#backup-posture) for the exact XML.
67
+
68
+ ### Remove the inherited storage permission (recommended)
69
+
70
+ `@dr.pogodin/react-native-fs` declares `WRITE_EXTERNAL_STORAGE` unconditionally in its own manifest, so it merges into your app whether or not you use it. **This package never writes outside the app-private documents directory** — every filesystem call is confined to `chipmobilesdk-localdb/` — so unless something else in your app needs it, the permission is declared and unused. That shows up in your Play listing and invites a data-safety question with no true answer.
71
+
72
+ ```xml
73
+ <!-- android/app/src/main/AndroidManifest.xml -->
74
+ <manifest xmlns:android="http://schemas.android.com/apk/res/android"
75
+ xmlns:tools="http://schemas.android.com/tools">
76
+
77
+ <uses-permission
78
+ android:name="android.permission.WRITE_EXTERNAL_STORAGE"
79
+ tools:node="remove" />
80
+ ```
81
+
82
+ `rn-local-db` itself requires **no permissions on either platform**.
83
+
84
+ ---
85
+
86
+ ## The scope model
87
+
88
+ A **scope** is the isolation boundary for all data. Every read and write happens inside exactly one, and a handle's scope is fixed when you open it.
89
+
90
+ There is no `switchScope`, and **no read, write, or query operation takes a scope argument.** That absence is the guarantee: a cross-scope leak is not something you are prevented from writing, it is something you cannot express.
91
+
92
+ ```ts
93
+ import { openDatabase } from '@chipmobilesdk/rn-local-db';
94
+
95
+ const guest = await openDatabase({ ...config, scope: { kind: 'guest' } });
96
+ const alice = await openDatabase({
97
+ ...config,
98
+ scope: { kind: 'identity', id: 'alice@example.com' },
99
+ });
100
+ const aliceAtAcme = await openDatabase({
101
+ ...config,
102
+ scope: { kind: 'identity', id: 'alice@example.com', tenant: 'acme' },
103
+ });
104
+ ```
105
+
106
+ Those are three different scopes holding three separate sets of data. The same record id can exist in all three, and neither overwrites the others.
107
+
108
+ **Scope components are treated as sensitive.** They are normalized, joined canonically, and hashed; only the SHA-256 digest reaches disk, an error, or a log. A directory named after an email address would be readable even when the database inside it is encrypted, and would travel in any crash report that includes a file path.
109
+
110
+ ```ts
111
+ alice.scopeKey; // "9f2c…" — the digest, never "alice@example.com"
112
+ ```
113
+
114
+ ### Worked example: an account switcher
115
+
116
+ ```ts
117
+ type Session = { handle: DatabaseHandle; scope: ScopeDescriptor };
118
+
119
+ const sessions = new Map<string, Session>();
120
+
121
+ async function signIn(accountId: string): Promise<Session> {
122
+ const scope = { kind: 'identity', id: accountId } as const;
123
+ const handle = await openDatabase({ ...config, scope });
124
+ const session = { handle, scope };
125
+ sessions.set(accountId, session);
126
+ return session;
127
+ }
128
+
129
+ async function signOut(accountId: string): Promise<void> {
130
+ // Closes the handle. Deletes nothing — see the deletion section.
131
+ await sessions.get(accountId)?.handle.close();
132
+ sessions.delete(accountId);
133
+ }
134
+
135
+ async function forgetMe(accountId: string): Promise<void> {
136
+ await signOut(accountId);
137
+ const outcome = await deleteScopeData({
138
+ name: config.name,
139
+ scope: { kind: 'identity', id: accountId },
140
+ });
141
+ console.log(`removed ${outcome.removedRecords} records`);
142
+ }
143
+ ```
144
+
145
+ Up to eight handles may be open at once, so keeping a previous account warm while the user switches is a supported arrangement rather than a leak.
146
+
147
+ ---
148
+
149
+ ## Declaring a schema
150
+
151
+ Declaring a field makes it **queryable**. It does not restrict what a record may contain: undeclared fields are stored and returned faithfully, they simply cannot be filtered or sorted on.
152
+
153
+ ```ts
154
+ const config: DatabaseConfig = {
155
+ name: 'my_app',
156
+ scope: { kind: 'guest' },
157
+ schemaVersion: 2,
158
+ collections: [
159
+ {
160
+ name: 'notes',
161
+ fields: [
162
+ { name: 'title', type: 'string' },
163
+ { name: 'body', type: 'string', nullable: true },
164
+ { name: 'score', type: 'number' },
165
+ { name: 'pinned', type: 'boolean' },
166
+ { name: 'updated', type: 'timestamp' },
167
+ ],
168
+ indexes: [
169
+ { name: 'notes_by_score', fields: ['score'] },
170
+ { name: 'notes_by_pinned_score', fields: ['pinned', 'score'] },
171
+ ],
172
+ softDelete: true, // opt-in
173
+ timestamps: true, // opt-in: the package maintains createdAt/updatedAt
174
+ },
175
+ ],
176
+ migrations: [/* see below */],
177
+ };
178
+ ```
179
+
180
+ Validation runs to completion **before any write**. A duplicate collection name, an index over an undeclared field, or a broken migration chain each raise before a file is touched.
181
+
182
+ ---
183
+
184
+ ## Records and the four value conditions
185
+
186
+ ```ts
187
+ const notes = handle.collection<Note>('notes');
188
+
189
+ await notes.insert({ id: 'n1', data: { title: 'First', score: 1, pinned: false } });
190
+ await notes.update('n1', { data: { title: 'Renamed', score: 1, pinned: false } });
191
+ const outcome = await notes.upsert({ id: 'n1', data: { …} });
192
+ outcome.operation; // 'created' | 'updated' — it tells you which it was
193
+
194
+ await notes.get('n1'); // rejects with RECORD_NOT_FOUND when absent
195
+ await notes.find('n1'); // returns null when absent
196
+
197
+ await notes.delete('n1'); // hard
198
+ await notes.softDelete('n1'); // requires softDelete: true
199
+ await notes.restore('n1');
200
+ ```
201
+
202
+ `get` and `find` both exist because these four states are four different answers, and an app that cannot tell them apart writes bugs that look like data loss:
203
+
204
+ | Condition | How you see it |
205
+ |---|---|
206
+ | The record does not exist | `find` returns `null`; `get` rejects with `RECORD_NOT_FOUND` |
207
+ | The field is present and null | `'body' in record.data` is `true`, `record.data.body` is `null` |
208
+ | The field is absent | `'body' in record.data` is `false` |
209
+ | The record is soft-deleted | Excluded from reads unless `includeSoftDeleted: true` |
210
+
211
+ These survive a write-and-read cycle because reads reconstruct the record from the stored payload alone. Nothing is coerced: numbers keep their precision, booleans stay booleans, nested structures come back identically.
212
+
213
+ Mutating the object you passed in does not change what was stored, and mutating what you got back does not change the store.
214
+
215
+ ---
216
+
217
+ ## Queries
218
+
219
+ ```ts
220
+ const page = await notes.list({
221
+ filter: {
222
+ op: 'and',
223
+ nodes: [
224
+ { op: 'gte', field: 'score', value: 80 },
225
+ { op: 'eq', field: 'pinned', value: true },
226
+ ],
227
+ },
228
+ sort: [{ field: 'score', direction: 'desc' }],
229
+ page: { size: 25, cursor: previousPage?.cursor },
230
+ });
231
+
232
+ page.records; // StoredRecord<Note>[]
233
+ page.hasMore; // whether another page exists
234
+ page.cursor; // opaque; absent on the final page
235
+
236
+ const total = await notes.count(filter); // agrees with the list, always
237
+ ```
238
+
239
+ **Operators**: `eq`, `ne`, `lt`, `lte`, `gt`, `gte`, `in`, `nin`, `between`, `isNull`, `isNotNull`, `exists`, combined with `and`, `or`, `not`.
240
+
241
+ `exists` is the one that answers a question about *presence*, which is what keeps "absent" distinguishable from "null". An absent field satisfies no other operator — not even `ne`.
242
+
243
+ **Paging is keyset-based**, anchored to the last row's sort values and the record id. Page 2,000 costs the same as page 1, and a concurrent write cannot make a record appear twice or vanish. `OFFSET` has neither property. The record id is always appended as the final sort key, so ties break deterministically.
244
+
245
+ **An invalid query is an error, never a result.** A filter on an undeclared field raises `QUERY_INVALID` rather than returning everything or nothing — both of which look like data and neither of which is.
246
+
247
+ ```ts
248
+ // Development-mode diagnostic: is this query actually using the index?
249
+ const report = await handle.explain('notes', query);
250
+ report.usesIndex; // answered by the engine's plan, not by reading declarations
251
+ ```
252
+
253
+ ---
254
+
255
+ ## Transactions and batches
256
+
257
+ ```ts
258
+ await handle.transaction(async tx => {
259
+ await tx.collection('orders').insert({ id: 'o1', data: order });
260
+ await tx.collection('lines').insert({ id: 'l1', data: line });
261
+ // Throw, or call tx.abort(), and neither write survives.
262
+ });
263
+ ```
264
+
265
+ Reads inside a transaction see that transaction's own writes; reads outside do not until it commits. A settled transaction rejects further use with `TRANSACTION_STATE`, so cleanup code cannot accidentally write outside the rollback it was meant to be part of.
266
+
267
+ **Inside the body, use what `tx` hands you** — `tx.collection()` and `tx.attachments()`. Work issued through the handle while a transaction runs waits for it to finish and then runs on its own; it never joins the transaction, so a rollback cannot take an unrelated write with it. A body that awaits the handle's own `collection()` or `attachments()` would be waiting on itself, so after 10 seconds that call is refused with `TRANSACTION_STATE` and the transaction rolls back instead of hanging.
268
+
269
+ A transaction never spans two scopes — with one database file per scope, that is a type error rather than a runtime check.
270
+
271
+ ```ts
272
+ const outcome = await handle.batch([
273
+ { type: 'upsert', collection: 'notes', record: { id: 'a', data } },
274
+ { type: 'softDelete', collection: 'notes', id: 'b' },
275
+ ]);
276
+ outcome.applied; // number
277
+ outcome.outcomes; // one WriteOutcome per operation
278
+ ```
279
+
280
+ A batch is one atomic unit, up to 1,000 operations. Every operation is resolved before any of them runs, so a conflict is reported with the database untouched.
281
+
282
+ ---
283
+
284
+ ## Migrations
285
+
286
+ ```ts
287
+ const migrations: Migration[] = [
288
+ { version: 1, migrate: async () => {} },
289
+ {
290
+ version: 2,
291
+ migrate: async ctx => {
292
+ const page = await ctx.collection('notes').list({ page: { size: 500 } });
293
+ for (const record of page.records) {
294
+ await ctx.collection('notes').upsert({
295
+ id: record.id,
296
+ data: { ...record.data, pinned: false },
297
+ });
298
+ }
299
+ },
300
+ },
301
+ ];
302
+ ```
303
+
304
+ Rules, each of which exists because the alternative loses data:
305
+
306
+ - **Ascending, pending only, at most once per scope.** The ledger is per scope, so an account created after a migration shipped runs the whole chain on first open rather than being assumed current.
307
+ - **One transaction per migration.** The recorded version advances only on success, inside the same transaction as the migration's own writes.
308
+ - **A failure returns no handle at all.** Not a degraded handle, not a read-only one — nothing. `MIGRATION_FAILED` carries `recordedVersion`, `failingVersion`, and `outcome` so you can tell a transient failure from a permanent one.
309
+ - **A retry starts from the same version.** Ten consecutive failed opens alter zero records.
310
+ - **A chain that does not reach `schemaVersion`, has a gap, or repeats a version is refused before anything runs**, with `MIGRATION_CONFIG_INVALID`.
311
+ - **Opening a database written by a newer schema raises `SCHEMA_DOWNGRADE`** and truncates nothing. A user who rolls back a build gets their data again when they roll forward.
312
+
313
+ ### Testing a migration
314
+
315
+ ```ts
316
+ import { createMemoryAdapter } from '@chipmobilesdk/rn-local-db/testing';
317
+
318
+ const adapter = createMemoryAdapter();
319
+ const v1 = await adapter.open({ ...config, schemaVersion: 1, migrations: [migrations[0]] });
320
+ await v1.collection('notes').insert({ id: 'n1', data: legacyShape });
321
+ await v1.close();
322
+
323
+ const v2 = await adapter.open({ ...config, schemaVersion: 2, migrations });
324
+ expect((await v2.collection('notes').get('n1')).data.pinned).toBe(false);
325
+ ```
326
+
327
+ ### Recovering from a broken migration
328
+
329
+ `resetScope` discards one scope's data and leaves it ready for a clean first run. It is **separately named and never reachable from `openDatabase`** on purpose: an automatic fallback would turn a bad migration shipped to production into silent data loss for every user who launched the app.
330
+
331
+ ```ts
332
+ const outcome = await resetScope({ name: config.name, scope });
333
+ outcome.removedRecords; // it tells you what it destroyed
334
+ ```
335
+
336
+ ---
337
+
338
+ ## Deletion, and why sign-out is not deletion
339
+
340
+ **The package never deletes anything unasked.** No expiry, no cache eviction, no sweep on app upgrade, nothing on `close()`. Closing a handle is what a sign-out looks like to this package, and it does nothing to the data.
341
+
342
+ ```ts
343
+ // Sign-out: the data survives, and is there when the user signs back in.
344
+ await handle.close();
345
+
346
+ // A deletion request: one scope, every collection, and nothing else.
347
+ const outcome = await deleteScopeData({ name: config.name, scope });
348
+
349
+ // Uninstall-equivalent: the whole database directory.
350
+ await deleteDatabase({ name: config.name });
351
+ ```
352
+
353
+ These are module-level functions rather than handle methods because they must work whether or not a handle is open on the target.
354
+
355
+ Every one returns a `DeletionOutcome` rather than a bare success — what was removed, what was orphaned, and what could not be removed. A file the package could not delete is still on disk holding the user's data, and an app that reported "deleted" would have said something untrue.
356
+
357
+ If a deletion is interrupted — a force-quit, a battery death — the next open reports `SCOPE_DELETED` rather than presenting the scope as healthy. Call `deleteScopeData` again to finish it.
358
+
359
+ A handle whose scope is deleted beneath it reports `SCOPE_DELETED` on its next use. The deletion does not wait for it, and nothing revives it: open a new handle and rebuild whatever was built on the old one. An app that retries through the dead handle gets the same error every time, which reaches the user as a screen that only relaunching can fix.
360
+
361
+ ---
362
+
363
+ ## Attachments
364
+
365
+ The package owns both halves: the file on disk and the reference to it.
366
+
367
+ ```ts
368
+ const attachments = handle.attachments();
369
+
370
+ const reference = await attachments.write({
371
+ id: 'photo-1',
372
+ owner: { collection: 'notes', id: 'n1' },
373
+ contentType: 'image/jpeg',
374
+ content: base64,
375
+ });
376
+
377
+ await attachments.read('photo-1'); // whole
378
+ await attachments.read('photo-1', { offset: 0, length: 4096 }); // a range
379
+ await attachments.availability('photo-1'); // never opens the file
380
+ await attachments.listFor({ collection: 'notes', id: 'n1' });
379
381
  await attachments.remove('photo-1');
380
382
  ```
381
383
 
@@ -383,193 +385,193 @@ await attachments.remove('photo-1');
383
385
  write contract therefore requires the app to hold the encoded payload, and the
384
386
  native adapter decodes that full string before processing encrypted chunks.
385
387
  Use range reads to avoid loading an entire stored artifact when consuming it.
386
-
387
- **Availability** answers from `stat()` and, for an encrypted artifact, the 22-byte header — never a content byte and never a decryption. A gallery showing fifty thumbnails does not decrypt fifty files to ask whether they are there.
388
-
389
- | State | Meaning |
390
- |---|---|
391
- | `available` | The file is there |
392
- | `missing` | The file is gone — data loss for that artifact |
393
- | `inaccessible` | The file exists but could not be reached |
394
- | `undecryptable` | The file exists and will not decrypt: a wrong key or a tampered file, often recoverable |
395
-
396
- **A missing artifact never makes its record unreadable.** A note with a broken image is worth more than a note that will not load.
397
-
398
- ### Cascade
399
-
400
- Set `cascadeAttachments: true` in the config and deleting a record removes its artifacts too. Off by default: deleting a user's files as a side effect is not a decision this package makes for you.
401
-
402
- ### Reconciliation
403
-
404
- A filesystem write cannot join a database transaction. So a rolled-back transaction leaves an artifact on disk with nothing pointing at it, and the operating system can reclaim a file the database still references. Both are real, and reconciliation is how you find them:
405
-
406
- ```ts
407
- const report = await attachments.reconcile();
408
- report.orphanedArtifacts; // files with no reference
409
- report.danglingReferences; // references with no file
410
-
411
- // Reclaim the disk. Off by default — deleting a file is never an implied yes.
412
- await attachments.reconcile({ removeOrphans: true });
413
- ```
414
-
415
- App-triggered only. There is no background sweep, because a sweep cannot tell an orphan from a file a transaction has not committed yet.
416
-
417
- ### The boundary rule
418
-
419
- Every filesystem operation is confined to the managed storage area. An attachment id must be a safe single path segment; anything else raises `ATTACHMENT_OUT_OF_BOUNDS` and touches no file.
420
-
421
- ---
422
-
423
- ## Encryption
424
-
425
- Opt-in, under a key **your app supplies**. The package never generates, derives from its own material, persists, caches beyond the handle, transmits, or returns a key — which is also why it has no keychain dependency.
426
-
427
- ```ts
428
- const keyProvider: KeyProvider = {
429
- async getKey(scope) {
430
- // scope.key is the digest, so you can key per identity.
431
- return Keychain.get(`db-key-${scope.key}`);
432
- },
433
- };
434
-
435
- const handle = await openDatabase({
436
- ...config,
437
- encryption: { enabled: true, keyProvider },
438
- });
439
- ```
440
-
441
- The provider receives the resolved scope so you can key per identity — which is what makes destroying one identity's key a meaningful answer to a deletion request, rather than a gesture that leaves the data readable under a shared key.
442
-
388
+
389
+ **Availability** answers from `stat()` and, for an encrypted artifact, the 22-byte header — never a content byte and never a decryption. A gallery showing fifty thumbnails does not decrypt fifty files to ask whether they are there.
390
+
391
+ | State | Meaning |
392
+ |---|---|
393
+ | `available` | The file is there |
394
+ | `missing` | The file is gone — data loss for that artifact |
395
+ | `inaccessible` | The file exists but could not be reached |
396
+ | `undecryptable` | The file exists and will not decrypt: a wrong key or a tampered file, often recoverable |
397
+
398
+ **A missing artifact never makes its record unreadable.** A note with a broken image is worth more than a note that will not load.
399
+
400
+ ### Cascade
401
+
402
+ Set `cascadeAttachments: true` in the config and deleting a record removes its artifacts too. Off by default: deleting a user's files as a side effect is not a decision this package makes for you.
403
+
404
+ ### Reconciliation
405
+
406
+ A filesystem write cannot join a database transaction. So a rolled-back transaction leaves an artifact on disk with nothing pointing at it, and the operating system can reclaim a file the database still references. Both are real, and reconciliation is how you find them:
407
+
408
+ ```ts
409
+ const report = await attachments.reconcile();
410
+ report.orphanedArtifacts; // files with no reference
411
+ report.danglingReferences; // references with no file
412
+
413
+ // Reclaim the disk. Off by default — deleting a file is never an implied yes.
414
+ await attachments.reconcile({ removeOrphans: true });
415
+ ```
416
+
417
+ App-triggered only. There is no background sweep, because a sweep cannot tell an orphan from a file a transaction has not committed yet.
418
+
419
+ ### The boundary rule
420
+
421
+ Every filesystem operation is confined to the managed storage area. An attachment id must be a safe single path segment; anything else raises `ATTACHMENT_OUT_OF_BOUNDS` and touches no file.
422
+
423
+ ---
424
+
425
+ ## Encryption
426
+
427
+ Opt-in, under a key **your app supplies**. The package never generates, derives from its own material, persists, caches beyond the handle, transmits, or returns a key — which is also why it has no keychain dependency.
428
+
429
+ ```ts
430
+ const keyProvider: KeyProvider = {
431
+ async getKey(scope) {
432
+ // scope.key is the digest, so you can key per identity.
433
+ return Keychain.get(`db-key-${scope.key}`);
434
+ },
435
+ };
436
+
437
+ const handle = await openDatabase({
438
+ ...config,
439
+ encryption: { enabled: true, keyProvider },
440
+ });
441
+ ```
442
+
443
+ The provider receives the resolved scope so you can key per identity — which is what makes destroying one identity's key a meaningful answer to a deletion request, rather than a gesture that leaves the data readable under a shared key.
444
+
443
445
  **Records** are protected by SQLCipher. **Artifacts** use chunked AES-256-GCM:
444
446
  256 KB chunks, a per-artifact subkey derived with HKDF, and an authentication
445
447
  tag per chunk. Encryption and range decryption operate by chunk, but the
446
448
  current write API first decodes the complete base64 input. Peak write memory is
447
449
  therefore not flat in artifact size; size large media before passing it to this
448
450
  version.
449
-
450
- A key-provider failure while the device is locked is an **expected condition**, not a bug:
451
-
452
- | Code | Means | Retryable |
453
- |---|---|---|
454
- | `KEY_PROVIDER_FAILED` | Your provider could not answer — usually a locked device | Yes |
455
- | `KEY_INVALID` | The key was wrong, empty, or unusable | No |
456
- | `ENCRYPTION_MISMATCH` | The stored database has a different posture than requested | No |
457
- | `ENCRYPTION_UNAVAILABLE` | Encryption was asked for and cannot be delivered | No |
458
-
459
- **None of the four opens, truncates, or recreates anything.** Each is a state where the data is still there and still recoverable.
460
-
461
- Changing posture in place is not supported in this version. To convert, export, delete the scope, and re-import.
462
-
463
- ---
464
-
465
- ## Backup posture
466
-
467
- Excluded from platform backup by default. The two platforms are not symmetric, and the package reports which one you are getting rather than smoothing over it.
468
-
469
- ```ts
470
- handle.backupPosture;
471
- // { requested: 'excluded', observed: 'excluded', enforcedBy: 'package' } // iOS
472
- // { requested: 'excluded', observed: 'unknown', enforcedBy: 'app-manifest', warning: '…' } // Android
473
- ```
474
-
475
- **iOS**: the package sets `NSURLIsExcludedFromBackupKey` on the managed root at creation, and the attribute is inherited by everything beneath it. Enforced.
476
-
477
- **Android**: participation is declared in your manifest, which is build-time and app-owned. A library that authors no native code cannot set it. The package verifies whether the managed area is where the documented rule addresses and reports the observed posture, warning when it cannot confirm.
478
-
479
- Ship both files — `dataExtractionRules` is read from Android 12, `fullBackupContent` below it:
480
-
481
- ```xml
482
- <!-- android/app/src/main/res/xml/data_extraction_rules.xml -->
483
- <data-extraction-rules>
484
- <cloud-backup>
485
- <exclude domain="file" path="chipmobilesdk-localdb/" />
486
- </cloud-backup>
487
- <device-transfer>
488
- <exclude domain="file" path="chipmobilesdk-localdb/" />
489
- </device-transfer>
490
- </data-extraction-rules>
491
- ```
492
-
493
- ```xml
494
- <!-- android/app/src/main/res/xml/backup_rules.xml -->
495
- <full-backup-content>
496
- <exclude domain="file" path="chipmobilesdk-localdb/" />
497
- </full-backup-content>
498
- ```
499
-
500
- ```xml
501
- <!-- android/app/src/main/AndroidManifest.xml -->
502
- <application
503
- android:dataExtractionRules="@xml/data_extraction_rules"
504
- android:fullBackupContent="@xml/backup_rules">
505
- ```
506
-
507
- To opt in instead, set `backup: { includeInPlatformBackup: true }` and remove the exclusion from your rules.
508
-
509
- ---
510
-
511
- ## Diagnostics
512
-
513
- Every failure is a `StorageError` with a stable `code`. **Never parse a message** — use `isCode`:
514
-
515
- ```ts
516
- import { DiagnosticCodes, isCode, isStorageError } from '@chipmobilesdk/rn-local-db';
517
-
518
- try {
519
- await notes.insert({ id, data });
520
- } catch (error) {
521
- if (isCode(error, DiagnosticCodes.DISK_FULL)) {
522
- return promptToFreeSpace();
523
- }
524
- if (isStorageError(error) && error.retryable) {
525
- return retryLater();
526
- }
527
- throw error;
528
- }
529
- ```
530
-
531
- **No error ever carries a record payload, a field value, a query parameter, attachment content, a scope component, or key material — in any build.** Redaction happens at construction rather than at logging, so an error object that never held a payload cannot leak one through a crash reporter. A native message is reduced to an engine identifier (`SQLITE_FULL`, `ENOENT`) or to `[redacted]`; `detail` keeps only allowlisted keys.
532
-
533
- ### Every diagnostic identifier
534
-
535
- | Code | Condition | Retryable | Recommended recovery |
536
- |---|---|---|---|
537
- | `CONFIG_INVALID` | The configuration is malformed | No | Fix the declaration; this is a developer error |
538
- | `INVALID_SCOPE` | The scope descriptor is missing, malformed, or too long | No | Correct the descriptor — it is never sanitized for you |
539
- | `MIGRATION_CONFIG_INVALID` | Duplicate version, a gap, or a chain short of `schemaVersion` | No | Fix the chain before shipping |
540
- | `OPEN_FAILED` | The database could not be opened | Yes | Retry; if it persists, report or offer a reset |
541
- | `CLOSED_HANDLE` | The handle was already closed | No | A lifecycle bug in the app |
542
- | `SCOPE_DELETED` | The scope was deleted, or a deletion was interrupted | No | Call `deleteScopeData` to finish, then reopen |
543
- | `CORRUPTION` | The database file is malformed | No | Offer `resetScope`; never assume it |
544
- | `STORAGE_ACCESS` | The filesystem refused | Yes | Retry after unlock |
545
- | `MIGRATION_FAILED` | A migration threw | Yes | Retry on next launch; ship a fix, or offer `resetScope` |
546
- | `SCHEMA_DOWNGRADE` | The stored schema is newer than the declared one | No | Do not open; the newer build reads it fine |
547
- | `RECORD_NOT_FOUND` | No record with that id | No | Refresh the list; the id is stale |
548
- | `RECORD_CONFLICT` | A record with that id already exists | No | Use `upsert`, or choose a different id |
549
- | `RECORD_TOO_LARGE` | Over 256 KB serialized | No | Store the bulk as an attachment |
550
- | `BATCH_TOO_LARGE` | Over 1,000 operations | No | Split the batch |
551
- | `DISK_FULL` | No space left | Yes | Prompt to free space, then retry |
552
- | `WRITE_FAILED` | The write failed for another reason | Yes | Retry once |
553
- | `QUERY_INVALID` | Undeclared field, unsupported operator, or wrong value shape | No | Fix the query; a developer error |
554
- | `PAGE_SIZE_INVALID` | Page size outside 1–500 | No | Clamp the requested size |
555
- | `CURSOR_INVALID` | The cursor is malformed or from a different query shape | No | Restart from the first page |
556
- | `TRANSACTION_FAILED` | The transaction could not commit | Yes | Retry the whole transaction |
557
- | `TRANSACTION_STATE` | Used after commit or abort | No | A lifecycle bug in the app |
558
- | `ATTACHMENT_MISSING` | The artifact is gone | No | Show the record without it; offer to re-attach |
559
- | `ATTACHMENT_OUT_OF_BOUNDS` | The id is not a safe path segment | No | Use `[A-Za-z0-9._-]`, no leading dot |
560
- | `ATTACHMENT_WRITE_FAILED` | The artifact could not be written | Yes | Retry; check free space |
561
- | `ATTACHMENT_UNDECRYPTABLE` | The artifact will not authenticate | No | A wrong key or a tampered file — not data loss |
562
- | `KEY_INVALID` | The key was unusable | No | Re-derive or re-provision the key |
563
- | `KEY_PROVIDER_FAILED` | Your provider could not answer | Yes | Retry after unlock |
564
- | `ENCRYPTION_MISMATCH` | Posture differs from what is stored | No | Export, delete, re-import |
565
- | `ENCRYPTION_UNAVAILABLE` | SQLCipher or the cipher peer is absent | No | Enable the build flag, or install the peer |
566
-
567
- ### Development logging
568
-
569
- ```ts
570
- setDiagnosticLogger(event => console.log(event.operation, event.durationMs, event.count));
571
- ```
572
-
451
+
452
+ A key-provider failure while the device is locked is an **expected condition**, not a bug:
453
+
454
+ | Code | Means | Retryable |
455
+ |---|---|---|
456
+ | `KEY_PROVIDER_FAILED` | Your provider could not answer — usually a locked device | Yes |
457
+ | `KEY_INVALID` | The key was wrong, empty, or unusable | No |
458
+ | `ENCRYPTION_MISMATCH` | The stored database has a different posture than requested | No |
459
+ | `ENCRYPTION_UNAVAILABLE` | Encryption was asked for and cannot be delivered | No |
460
+
461
+ **None of the four opens, truncates, or recreates anything.** Each is a state where the data is still there and still recoverable.
462
+
463
+ Changing posture in place is not supported in this version. To convert, export, delete the scope, and re-import.
464
+
465
+ ---
466
+
467
+ ## Backup posture
468
+
469
+ Excluded from platform backup by default. The two platforms are not symmetric, and the package reports which one you are getting rather than smoothing over it.
470
+
471
+ ```ts
472
+ handle.backupPosture;
473
+ // { requested: 'excluded', observed: 'excluded', enforcedBy: 'package' } // iOS
474
+ // { requested: 'excluded', observed: 'unknown', enforcedBy: 'app-manifest', warning: '…' } // Android
475
+ ```
476
+
477
+ **iOS**: the package sets `NSURLIsExcludedFromBackupKey` on the managed root at creation, and the attribute is inherited by everything beneath it. Enforced.
478
+
479
+ **Android**: participation is declared in your manifest, which is build-time and app-owned. A library that authors no native code cannot set it. The package verifies whether the managed area is where the documented rule addresses and reports the observed posture, warning when it cannot confirm.
480
+
481
+ Ship both files — `dataExtractionRules` is read from Android 12, `fullBackupContent` below it:
482
+
483
+ ```xml
484
+ <!-- android/app/src/main/res/xml/data_extraction_rules.xml -->
485
+ <data-extraction-rules>
486
+ <cloud-backup>
487
+ <exclude domain="file" path="chipmobilesdk-localdb/" />
488
+ </cloud-backup>
489
+ <device-transfer>
490
+ <exclude domain="file" path="chipmobilesdk-localdb/" />
491
+ </device-transfer>
492
+ </data-extraction-rules>
493
+ ```
494
+
495
+ ```xml
496
+ <!-- android/app/src/main/res/xml/backup_rules.xml -->
497
+ <full-backup-content>
498
+ <exclude domain="file" path="chipmobilesdk-localdb/" />
499
+ </full-backup-content>
500
+ ```
501
+
502
+ ```xml
503
+ <!-- android/app/src/main/AndroidManifest.xml -->
504
+ <application
505
+ android:dataExtractionRules="@xml/data_extraction_rules"
506
+ android:fullBackupContent="@xml/backup_rules">
507
+ ```
508
+
509
+ To opt in instead, set `backup: { includeInPlatformBackup: true }` and remove the exclusion from your rules.
510
+
511
+ ---
512
+
513
+ ## Diagnostics
514
+
515
+ Every failure is a `StorageError` with a stable `code`. **Never parse a message** — use `isCode`:
516
+
517
+ ```ts
518
+ import { DiagnosticCodes, isCode, isStorageError } from '@chipmobilesdk/rn-local-db';
519
+
520
+ try {
521
+ await notes.insert({ id, data });
522
+ } catch (error) {
523
+ if (isCode(error, DiagnosticCodes.DISK_FULL)) {
524
+ return promptToFreeSpace();
525
+ }
526
+ if (isStorageError(error) && error.retryable) {
527
+ return retryLater();
528
+ }
529
+ throw error;
530
+ }
531
+ ```
532
+
533
+ **No error ever carries a record payload, a field value, a query parameter, attachment content, a scope component, or key material — in any build.** Redaction happens at construction rather than at logging, so an error object that never held a payload cannot leak one through a crash reporter. A native message is reduced to an engine identifier (`SQLITE_FULL`, `ENOENT`) or to `[redacted]`; `detail` keeps only allowlisted keys.
534
+
535
+ ### Every diagnostic identifier
536
+
537
+ | Code | Condition | Retryable | Recommended recovery |
538
+ |---|---|---|---|
539
+ | `CONFIG_INVALID` | The configuration is malformed | No | Fix the declaration; this is a developer error |
540
+ | `INVALID_SCOPE` | The scope descriptor is missing, malformed, or too long | No | Correct the descriptor — it is never sanitized for you |
541
+ | `MIGRATION_CONFIG_INVALID` | Duplicate version, a gap, or a chain short of `schemaVersion` | No | Fix the chain before shipping |
542
+ | `OPEN_FAILED` | The database could not be opened | Yes | Retry; if it persists, report or offer a reset |
543
+ | `CLOSED_HANDLE` | The handle was already closed | No | A lifecycle bug in the app |
544
+ | `SCOPE_DELETED` | The scope was deleted, or a deletion was interrupted | No | Call `deleteScopeData` to finish, then reopen |
545
+ | `CORRUPTION` | The database file is malformed | No | Offer `resetScope`; never assume it |
546
+ | `STORAGE_ACCESS` | The filesystem refused | Yes | Retry after unlock |
547
+ | `MIGRATION_FAILED` | A migration threw | Yes | Retry on next launch; ship a fix, or offer `resetScope` |
548
+ | `SCHEMA_DOWNGRADE` | The stored schema is newer than the declared one | No | Do not open; the newer build reads it fine |
549
+ | `RECORD_NOT_FOUND` | No record with that id | No | Refresh the list; the id is stale |
550
+ | `RECORD_CONFLICT` | A record with that id already exists | No | Use `upsert`, or choose a different id |
551
+ | `RECORD_TOO_LARGE` | Over 256 KB serialized | No | Store the bulk as an attachment |
552
+ | `BATCH_TOO_LARGE` | Over 1,000 operations | No | Split the batch |
553
+ | `DISK_FULL` | No space left | Yes | Prompt to free space, then retry |
554
+ | `WRITE_FAILED` | The write failed for another reason | Yes | Retry once |
555
+ | `QUERY_INVALID` | Undeclared field, unsupported operator, or wrong value shape | No | Fix the query; a developer error |
556
+ | `PAGE_SIZE_INVALID` | Page size outside 1–500 | No | Clamp the requested size |
557
+ | `CURSOR_INVALID` | The cursor is malformed or from a different query shape | No | Restart from the first page |
558
+ | `TRANSACTION_FAILED` | The transaction could not commit | Yes | Retry the whole transaction |
559
+ | `TRANSACTION_STATE` | Used after commit or abort | No | A lifecycle bug in the app |
560
+ | `ATTACHMENT_MISSING` | The artifact is gone | No | Show the record without it; offer to re-attach |
561
+ | `ATTACHMENT_OUT_OF_BOUNDS` | The id is not a safe path segment | No | Use `[A-Za-z0-9._-]`, no leading dot |
562
+ | `ATTACHMENT_WRITE_FAILED` | The artifact could not be written | Yes | Retry; check free space |
563
+ | `ATTACHMENT_UNDECRYPTABLE` | The artifact will not authenticate | No | A wrong key or a tampered file — not data loss |
564
+ | `KEY_INVALID` | The key was unusable | No | Re-derive or re-provision the key |
565
+ | `KEY_PROVIDER_FAILED` | Your provider could not answer | Yes | Retry after unlock |
566
+ | `ENCRYPTION_MISMATCH` | Posture differs from what is stored | No | Export, delete, re-import |
567
+ | `ENCRYPTION_UNAVAILABLE` | SQLCipher or the cipher peer is absent | No | Enable the build flag, or install the peer |
568
+
569
+ ### Development logging
570
+
571
+ ```ts
572
+ setDiagnosticLogger(event => console.log(event.operation, event.durationMs, event.count));
573
+ ```
574
+
573
575
  Operation names, durations, counts, and outcome codes. There is deliberately no path that accepts a payload, and the logger is inert in production builds.
574
576
 
575
577
  ---
@@ -597,115 +599,115 @@ private and unsupported.
597
599
  ---
598
600
 
599
601
  ## Testing against the in-memory adapter
600
-
601
- ```ts
602
- import { createMemoryAdapter, resetMemoryAdapter } from '@chipmobilesdk/rn-local-db/testing';
603
-
604
- const adapter = createMemoryAdapter();
605
- beforeEach(() => resetMemoryAdapter(adapter));
606
-
607
- it('archives a note', async () => {
608
- const handle = await adapter.open(config);
609
- const repository = new NoteRepository(handle); // your production class
610
- await repository.archive('n1');
611
- expect(await repository.findById('n1')).toBeNull();
612
- await handle.close();
613
- });
614
- ```
615
-
616
- A repository written against the interface needs **no source change** to run here. Seeding goes through the same write operations production code uses, so a test cannot set up data the package would have refused.
617
-
618
- ```ts
619
- import { injectFault, clearFaults, DiagnosticCodes } from '@chipmobilesdk/rn-local-db/testing';
620
-
621
- injectFault({ at: 'write', code: DiagnosticCodes.DISK_FULL, times: 1 });
622
- // …assert your recovery path…
623
- clearFaults();
624
- ```
625
-
626
- Fault points: `open`, `read`, `write`, `commit`, `migration`, `attachmentWrite`, `keyProvider`.
627
-
628
- ### Running the conformance suite
629
-
630
- The suite is the executable definition of what the package guarantees, and it is exported so you can run it against your own adapter if you ever write one:
631
-
632
- ```ts
633
- import { runConformanceSuite } from '@chipmobilesdk/rn-local-db/testing';
634
-
635
- runConformanceSuite(() => myAdapter, reset, hooks);
636
- ```
637
-
638
- ### Documented divergences
639
-
640
- The in-memory adapter is faithful, not identical. These six differences are known, deliberate, and the complete list — anything else that differs is a bug:
641
-
642
- | Divergence | Why |
643
- |---|---|
644
- | **Index coverage**: `explain()` always reports `usesIndex: false` | There is no query planner in memory. Index declarations are validated but have no performance effect. |
645
- | **Open-handle budget**: not enforced | The budget exists because each handle holds a native connection and its write-ahead log. In memory there is nothing to exhaust. |
646
- | **Encryption**: the posture is recorded, no ciphertext is produced | Key acquisition, mismatch detection, and every failure code behave identically; the bytes do not. |
647
- | **Backup posture**: reports the requested value | There is no filesystem to set an attribute on and no manifest to verify. |
648
- | **Artifact chunking**: content is stored whole | Chunk boundaries, nonces, and authentication tags are exercised by the cipher's own tests. |
649
- | **Corruption and disk-full**: not spontaneously reachable | Both are device conditions. Use `injectFault` to reach the codes. |
650
-
651
- ---
652
-
653
- ## Budgets
654
-
655
- Four are **hard limits** with a typed error. The rest are soft: exceeding them is slow, not an error — refusing the 100,001st write would destroy data the app already holds.
656
-
657
- | Budget | Value | Hard? | Behaviour past it |
658
- |---|---|---|---|
659
- | Record size | 256 KB serialized | **Yes** | `RECORD_TOO_LARGE` |
660
- | Batch operations | 1,000 | **Yes** | `BATCH_TOO_LARGE` |
661
- | Page size | 1–500 | **Yes** | `PAGE_SIZE_INVALID` |
662
- | Artifact size | 100 MB | **Yes** | `ATTACHMENT_WRITE_FAILED` |
663
- | Records per collection per scope | 100,000 | No | Query latency grows; unindexed queries degrade first |
664
- | Collections per database | 50 | **Yes** at open | `CONFIG_INVALID` |
665
- | Attachment references per scope | 50,000 | No | Reconciliation slows in proportion |
666
- | Concurrently open handles | 8 | **Yes** | `OPEN_FAILED` |
667
-
668
- `Budgets` is exported, so you can check before you write rather than after.
669
-
670
- ---
671
-
672
- ## Compatibility
673
-
674
- | | |
675
- |---|---|
676
- | React | 19.2+ |
677
- | React Native | 0.85+ (New Architecture) |
678
- | Node (tooling) | 22.11+ |
679
- | Android | `minSdkVersion` 24, `targetSdkVersion` 36 |
680
- | iOS | 15.1+ |
681
- | `@op-engineering/op-sqlite` | >= 17.1.0 |
682
- | `@dr.pogodin/react-native-fs` | >= 2.39.2 |
683
- | `react-native-quick-crypto` | >= 1.0.0 — **optional**, only for encryption |
684
-
685
- **Storage location**: app-private documents directory, under `chipmobilesdk-localdb/`.
686
- **Backup mechanism**: `NSURLIsExcludedFromBackupKey` on iOS; `dataExtractionRules` / `fullBackupContent` on Android.
687
- **File protection**: iOS default data protection applies; on Android the app sandbox applies. Neither is a substitute for `encryption`, which is what protects a file that has been extracted or restored.
688
-
689
- Zero runtime dependencies. No dependency on any `@chipmobilesdk/*` package.
690
-
691
- ---
692
-
693
- ## What this package stores, and where
694
-
695
- Written for the privacy disclosure a consuming app has to make.
696
-
697
- **What is stored**: exactly the records and files your app writes. The package generates no data of its own beyond a scope digest, timestamps you opted into, and a migration ledger.
698
-
699
- **Where**: the app-private documents directory, under `chipmobilesdk-localdb/<databaseName>/` — a catalog database, one SQLite file per scope, and a per-scope artifact directory. Nothing is written anywhere else, ever.
700
-
701
- **What leaves the device**: nothing. The package makes no network request under any configuration, and the filesystem surface it uses does not include the peer's upload and download helpers.
702
-
703
- **Protection at rest**: platform file protection by default; SQLCipher plus chunked AES-256-GCM for artifacts when `encryption` is enabled. The key is yours; the package holds it only for the lifetime of the handle.
704
-
705
- **Backup**: excluded by default. Enforced on iOS, declared by your manifest on Android. Opting in with `includeInPlatformBackup: true` means the user's local data travels to their platform backup, which is a disclosure you may need to make.
706
-
707
- **Deletion**: `deleteScopeData` removes one scope's records and artifacts and reports what it removed. That satisfies a deletion request for the data this package stores. It does **not** guarantee anything about residual data outside the package: filesystem free-space is not overwritten, and platform backups already taken are unaffected. If the scope was encrypted with a per-identity key, destroying that key — which your app controls — is what makes any residue unreadable.
708
-
602
+
603
+ ```ts
604
+ import { createMemoryAdapter, resetMemoryAdapter } from '@chipmobilesdk/rn-local-db/testing';
605
+
606
+ const adapter = createMemoryAdapter();
607
+ beforeEach(() => resetMemoryAdapter(adapter));
608
+
609
+ it('archives a note', async () => {
610
+ const handle = await adapter.open(config);
611
+ const repository = new NoteRepository(handle); // your production class
612
+ await repository.archive('n1');
613
+ expect(await repository.findById('n1')).toBeNull();
614
+ await handle.close();
615
+ });
616
+ ```
617
+
618
+ A repository written against the interface needs **no source change** to run here. Seeding goes through the same write operations production code uses, so a test cannot set up data the package would have refused.
619
+
620
+ ```ts
621
+ import { injectFault, clearFaults, DiagnosticCodes } from '@chipmobilesdk/rn-local-db/testing';
622
+
623
+ injectFault({ at: 'write', code: DiagnosticCodes.DISK_FULL, times: 1 });
624
+ // …assert your recovery path…
625
+ clearFaults();
626
+ ```
627
+
628
+ Fault points: `open`, `read`, `write`, `commit`, `migration`, `attachmentWrite`, `keyProvider`.
629
+
630
+ ### Running the conformance suite
631
+
632
+ The suite is the executable definition of what the package guarantees, and it is exported so you can run it against your own adapter if you ever write one:
633
+
634
+ ```ts
635
+ import { runConformanceSuite } from '@chipmobilesdk/rn-local-db/testing';
636
+
637
+ runConformanceSuite(() => myAdapter, reset, hooks);
638
+ ```
639
+
640
+ ### Documented divergences
641
+
642
+ The in-memory adapter is faithful, not identical. These six differences are known, deliberate, and the complete list — anything else that differs is a bug:
643
+
644
+ | Divergence | Why |
645
+ |---|---|
646
+ | **Index coverage**: `explain()` always reports `usesIndex: false` | There is no query planner in memory. Index declarations are validated but have no performance effect. |
647
+ | **Open-handle budget**: not enforced | The budget exists because each handle holds a native connection and its write-ahead log. In memory there is nothing to exhaust. |
648
+ | **Encryption**: the posture is recorded, no ciphertext is produced | Key acquisition, mismatch detection, and every failure code behave identically; the bytes do not. |
649
+ | **Backup posture**: reports the requested value | There is no filesystem to set an attribute on and no manifest to verify. |
650
+ | **Artifact chunking**: content is stored whole | Chunk boundaries, nonces, and authentication tags are exercised by the cipher's own tests. |
651
+ | **Corruption and disk-full**: not spontaneously reachable | Both are device conditions. Use `injectFault` to reach the codes. |
652
+
653
+ ---
654
+
655
+ ## Budgets
656
+
657
+ Four are **hard limits** with a typed error. The rest are soft: exceeding them is slow, not an error — refusing the 100,001st write would destroy data the app already holds.
658
+
659
+ | Budget | Value | Hard? | Behaviour past it |
660
+ |---|---|---|---|
661
+ | Record size | 256 KB serialized | **Yes** | `RECORD_TOO_LARGE` |
662
+ | Batch operations | 1,000 | **Yes** | `BATCH_TOO_LARGE` |
663
+ | Page size | 1–500 | **Yes** | `PAGE_SIZE_INVALID` |
664
+ | Artifact size | 100 MB | **Yes** | `ATTACHMENT_WRITE_FAILED` |
665
+ | Records per collection per scope | 100,000 | No | Query latency grows; unindexed queries degrade first |
666
+ | Collections per database | 50 | **Yes** at open | `CONFIG_INVALID` |
667
+ | Attachment references per scope | 50,000 | No | Reconciliation slows in proportion |
668
+ | Concurrently open handles | 8 | **Yes** | `OPEN_FAILED` |
669
+
670
+ `Budgets` is exported, so you can check before you write rather than after.
671
+
672
+ ---
673
+
674
+ ## Compatibility
675
+
676
+ | | |
677
+ |---|---|
678
+ | React | 19.2+ |
679
+ | React Native | 0.85+ (New Architecture) |
680
+ | Node (tooling) | 22.11+ |
681
+ | Android | `minSdkVersion` 24, `targetSdkVersion` 36 |
682
+ | iOS | 15.1+ |
683
+ | `@op-engineering/op-sqlite` | >= 17.1.0 |
684
+ | `@dr.pogodin/react-native-fs` | >= 2.39.2 |
685
+ | `react-native-quick-crypto` | >= 1.0.0 — **optional**, only for encryption. Its own peer `react-native-quick-base64` must also be in the **app's** `dependencies`: autolinking compiles only what the app declares, and without it the first encrypted attachment fails with `'QuickBase64' could not be found` |
686
+
687
+ **Storage location**: app-private documents directory, under `chipmobilesdk-localdb/`.
688
+ **Backup mechanism**: `NSURLIsExcludedFromBackupKey` on iOS; `dataExtractionRules` / `fullBackupContent` on Android.
689
+ **File protection**: iOS default data protection applies; on Android the app sandbox applies. Neither is a substitute for `encryption`, which is what protects a file that has been extracted or restored.
690
+
691
+ Zero runtime dependencies. No dependency on any `@chipmobilesdk/*` package.
692
+
693
+ ---
694
+
695
+ ## What this package stores, and where
696
+
697
+ Written for the privacy disclosure a consuming app has to make.
698
+
699
+ **What is stored**: exactly the records and files your app writes. The package generates no data of its own beyond a scope digest, timestamps you opted into, and a migration ledger.
700
+
701
+ **Where**: the app-private documents directory, under `chipmobilesdk-localdb/<databaseName>/` — a catalog database, one SQLite file per scope, and a per-scope artifact directory. Nothing is written anywhere else, ever.
702
+
703
+ **What leaves the device**: nothing. The package makes no network request under any configuration, and the filesystem surface it uses does not include the peer's upload and download helpers.
704
+
705
+ **Protection at rest**: platform file protection by default; SQLCipher plus chunked AES-256-GCM for artifacts when `encryption` is enabled. The key is yours; the package holds it only for the lifetime of the handle.
706
+
707
+ **Backup**: excluded by default. Enforced on iOS, declared by your manifest on Android. Opting in with `includeInPlatformBackup: true` means the user's local data travels to their platform backup, which is a disclosure you may need to make.
708
+
709
+ **Deletion**: `deleteScopeData` removes one scope's records and artifacts and reports what it removed. That satisfies a deletion request for the data this package stores. It does **not** guarantee anything about residual data outside the package: filesystem free-space is not overwritten, and platform backups already taken are unaffected. If the scope was encrypted with a per-identity key, destroying that key — which your app controls — is what makes any residue unreadable.
710
+
709
711
  **Your remaining obligations**: this package collects nothing and declares nothing on your behalf. If your app stores health, financial, or other sensitive categories in it, those categories are yours to declare in App Privacy and Data Safety.
710
712
 
711
713
  ---
@@ -717,9 +719,9 @@ the testing entrypoint's memory adapter so the UI can run without native
717
719
  database setup. Run from the repository root:
718
720
 
719
721
  ```bash
720
- npm run typecheck:local-db
721
- npm run pack:local-db
722
- npm run validate:local-db
722
+ npm run typecheck --workspace @chipmobilesdk/rn-local-db
723
+ npm run pack:check --workspace @chipmobilesdk/rn-local-db
724
+ npm run validate
723
725
  ```
724
726
 
725
727
  Physical-device SQLCipher, backup, corruption, and large-artifact evidence are