@chipmobilesdk/rn-local-db 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +191 -102
- package/README.md +728 -728
- package/package.json +62 -62
- package/skills/sdk-rn-local-db/SKILL.md +0 -1
- package/src/adapter/budgets.ts +67 -67
- package/src/adapter/collection.ts +303 -298
- package/src/adapter/deletion.ts +252 -252
- package/src/adapter/index.ts +64 -68
- package/src/adapter/lifecycle.ts +517 -520
- package/src/adapter/transaction.ts +71 -71
- package/src/artifacts/availability.ts +67 -67
- package/src/artifacts/cascade.ts +114 -114
- package/src/artifacts/paths.ts +29 -29
- package/src/artifacts/reconcile.ts +63 -63
- package/src/artifacts/store.ts +334 -345
- package/src/contract/adapter.ts +76 -78
- package/src/contract/attachment.ts +75 -78
- package/src/contract/backup.ts +28 -28
- package/src/contract/config.ts +54 -54
- package/src/contract/encryption.ts +34 -34
- package/src/contract/errors.ts +76 -76
- package/src/contract/migration.ts +42 -42
- package/src/contract/query.ts +81 -81
- package/src/contract/record.ts +46 -51
- package/src/contract/schema.ts +36 -36
- package/src/contract/scope.ts +36 -36
- package/src/contract/transaction.ts +52 -54
- package/src/crypto/chunked-cipher.ts +153 -150
- package/src/crypto/derive.ts +64 -64
- package/src/crypto/key-provider.ts +78 -78
- package/src/crypto/provider.ts +75 -81
- package/src/diagnostics/dev-log.ts +132 -108
- package/src/diagnostics/errors.ts +63 -66
- package/src/diagnostics/faults.ts +58 -64
- package/src/diagnostics/map-native.ts +108 -106
- package/src/diagnostics/redact.ts +119 -141
- package/src/index.ts +80 -110
- package/src/migration/executor.ts +132 -136
- package/src/migration/ledger.ts +95 -97
- package/src/migration/validate-chain.ts +65 -65
- package/src/platform/backup-android-verify.ts +88 -88
- package/src/platform/backup-ios.ts +49 -48
- package/src/platform/bytes.ts +157 -144
- package/src/platform/fs.ts +90 -89
- package/src/platform/location.ts +135 -135
- package/src/query/budgets.ts +54 -54
- package/src/query/compare.ts +91 -91
- package/src/query/cursor.ts +100 -100
- package/src/query/filter.ts +82 -82
- package/src/query/paginate.ts +51 -51
- package/src/query/sort.ts +55 -56
- package/src/query/validate.ts +171 -171
- package/src/schema/declare.ts +42 -45
- package/src/schema/validate-config.ts +158 -158
- package/src/scope/hash.ts +131 -134
- package/src/scope/normalize.ts +83 -90
- package/src/scope/validate.ts +83 -83
- package/src/sqlite/adapter.ts +26 -26
- package/src/sqlite/batch.ts +242 -242
- package/src/sqlite/catalog.ts +105 -108
- package/src/sqlite/connection.ts +227 -227
- package/src/sqlite/index-coverage.ts +54 -56
- package/src/sqlite/native.ts +84 -83
- package/src/sqlite/row-codec.ts +217 -221
- package/src/sqlite/sql-builder.ts +244 -244
- package/src/sqlite/transaction-queue.ts +87 -87
- package/src/sqlite/transactions.ts +33 -33
- package/src/sqlite/values.ts +24 -24
- package/src/testing/conformance/attachments.ts +139 -136
- package/src/testing/conformance/encryption.ts +151 -150
- package/src/testing/conformance/index.ts +161 -161
- package/src/testing/conformance/lifecycle.ts +99 -99
- package/src/testing/conformance/migrations.ts +177 -177
- package/src/testing/conformance/queries.ts +204 -167
- package/src/testing/conformance/records.ts +169 -169
- package/src/testing/conformance/scopes.ts +86 -88
- package/src/testing/conformance/transactions.ts +149 -149
- package/src/testing/faults.ts +21 -21
- package/src/testing/index.ts +17 -21
- package/src/testing/memory-adapter/collection.ts +214 -219
- package/src/testing/memory-adapter/index.ts +511 -492
- package/src/testing/memory-adapter/migrations.ts +117 -119
- package/src/testing/memory-adapter/reset.ts +50 -50
- package/src/testing/memory-adapter/store.ts +128 -134
package/README.md
CHANGED
|
@@ -1,728 +1,728 @@
|
|
|
1
|
-
# @chipmobilesdk/rn-local-db
|
|
2
|
-
|
|
3
|
-
Offline-first, scope-isolated local storage for ChipMobileSdk apps.
|
|
4
|
-
|
|
5
|
-
> **License:** `UNLICENSED`. Published publicly for use by the owner's
|
|
6
|
-
> applications; no open-source license is granted.
|
|
7
|
-
|
|
8
|
-
- **Version**:
|
|
9
|
-
- **Entrypoints**: `@chipmobilesdk/rn-local-db` and the test-only
|
|
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)
|
|
31
|
-
- [Diagnostics](#diagnostics)
|
|
32
|
-
- [Public API](#public-api)
|
|
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 (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' });
|
|
381
|
-
await attachments.remove('photo-1');
|
|
382
|
-
```
|
|
383
|
-
|
|
384
|
-
`AttachmentInput.content` is one complete base64 string. The current public
|
|
385
|
-
write contract therefore requires the app to hold the encoded payload, and the
|
|
386
|
-
native adapter decodes that full string before processing encrypted chunks.
|
|
387
|
-
Use range reads to avoid loading an entire stored artifact when consuming it.
|
|
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
|
-
|
|
445
|
-
**Records** are protected by SQLCipher. **Artifacts** use chunked AES-256-GCM:
|
|
446
|
-
256 KB chunks, a per-artifact subkey derived with HKDF, and an authentication
|
|
447
|
-
tag per chunk. Encryption and range decryption operate by chunk, but the
|
|
448
|
-
current write API first decodes the complete base64 input. Peak write memory is
|
|
449
|
-
therefore not flat in artifact size; size large media before passing it to this
|
|
450
|
-
version.
|
|
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
|
-
|
|
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.
|
|
576
|
-
|
|
577
|
-
---
|
|
578
|
-
|
|
579
|
-
## Public API
|
|
580
|
-
|
|
581
|
-
Production code imports only from `@chipmobilesdk/rn-local-db`:
|
|
582
|
-
|
|
583
|
-
- Lifecycle: `openDatabase`, `deleteScopeData`, `resetScope`, `deleteDatabase`
|
|
584
|
-
- Handle contract: `DatabaseHandle`, `CollectionOps`, `Transaction`,
|
|
585
|
-
`Attachments`, `StorageAdapter`, plus batch/transaction result types
|
|
586
|
-
- Configuration and isolation: database/target/scope descriptors, collection
|
|
587
|
-
schema, migrations, encryption/key provider, and backup posture types
|
|
588
|
-
- Records and queries: record/value/write types, filter/sort/page/query types,
|
|
589
|
-
`IndexCoverageReport`, and `Budgets`
|
|
590
|
-
- Attachments: input/reference/owner/range/availability/deletion/reconciliation
|
|
591
|
-
types
|
|
592
|
-
- Diagnostics: `StorageError`, `DiagnosticCodes`, `DiagnosticCode`, `isCode`,
|
|
593
|
-
`isStorageError`, `setDiagnosticLogger`, `DiagnosticLogger`
|
|
594
|
-
|
|
595
|
-
Tests import `createMemoryAdapter`, reset/seed/fault helpers, and the conformance
|
|
596
|
-
suite only from `@chipmobilesdk/rn-local-db/testing`. Other package paths are
|
|
597
|
-
private and unsupported.
|
|
598
|
-
|
|
599
|
-
---
|
|
600
|
-
|
|
601
|
-
## Testing against the in-memory adapter
|
|
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
|
-
|
|
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.
|
|
712
|
-
|
|
713
|
-
---
|
|
714
|
-
|
|
715
|
-
## Demo and validation
|
|
716
|
-
|
|
717
|
-
The demo app exposes ten `src/screens/LocalDb*Screen.tsx` scenarios and uses
|
|
718
|
-
the testing entrypoint's memory adapter so the UI can run without native
|
|
719
|
-
database setup. Run from the repository root:
|
|
720
|
-
|
|
721
|
-
```bash
|
|
722
|
-
npm run typecheck
|
|
723
|
-
npm run pack:local-db
|
|
724
|
-
npm run validate
|
|
725
|
-
```
|
|
726
|
-
|
|
727
|
-
Physical-device SQLCipher, backup, corruption, and large-artifact evidence are
|
|
728
|
-
separate release gates; the memory demo does not prove those native behaviors.
|
|
1
|
+
# @chipmobilesdk/rn-local-db
|
|
2
|
+
|
|
3
|
+
Offline-first, scope-isolated local storage for ChipMobileSdk apps.
|
|
4
|
+
|
|
5
|
+
> **License:** `UNLICENSED`. Published publicly for use by the owner's
|
|
6
|
+
> applications; no open-source license is granted.
|
|
7
|
+
|
|
8
|
+
- **Version**: see [CHANGELOG](CHANGELOG.md)
|
|
9
|
+
- **Entrypoints**: `@chipmobilesdk/rn-local-db` and the test-only
|
|
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)
|
|
31
|
+
- [Diagnostics](#diagnostics)
|
|
32
|
+
- [Public API](#public-api)
|
|
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 (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' });
|
|
381
|
+
await attachments.remove('photo-1');
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
`AttachmentInput.content` is one complete base64 string. The current public
|
|
385
|
+
write contract therefore requires the app to hold the encoded payload, and the
|
|
386
|
+
native adapter decodes that full string before processing encrypted chunks.
|
|
387
|
+
Use range reads to avoid loading an entire stored artifact when consuming it.
|
|
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
|
+
|
|
445
|
+
**Records** are protected by SQLCipher. **Artifacts** use chunked AES-256-GCM:
|
|
446
|
+
256 KB chunks, a per-artifact subkey derived with HKDF, and an authentication
|
|
447
|
+
tag per chunk. Encryption and range decryption operate by chunk, but the
|
|
448
|
+
current write API first decodes the complete base64 input. Peak write memory is
|
|
449
|
+
therefore not flat in artifact size; size large media before passing it to this
|
|
450
|
+
version.
|
|
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`. `StorageError` extends `SdkError` from `@chipmobilesdk/rn-core`, so `isSdkError(error)` recognises it alongside every other package's errors, `error.pkg` names this package, and `error.redactedCause` (when present) is the underlying failure reduced to log-safe text. **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
|
+
|
|
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.
|
|
576
|
+
|
|
577
|
+
---
|
|
578
|
+
|
|
579
|
+
## Public API
|
|
580
|
+
|
|
581
|
+
Production code imports only from `@chipmobilesdk/rn-local-db`:
|
|
582
|
+
|
|
583
|
+
- Lifecycle: `openDatabase`, `deleteScopeData`, `resetScope`, `deleteDatabase`
|
|
584
|
+
- Handle contract: `DatabaseHandle`, `CollectionOps`, `Transaction`,
|
|
585
|
+
`Attachments`, `StorageAdapter`, plus batch/transaction result types
|
|
586
|
+
- Configuration and isolation: database/target/scope descriptors, collection
|
|
587
|
+
schema, migrations, encryption/key provider, and backup posture types
|
|
588
|
+
- Records and queries: record/value/write types, filter/sort/page/query types,
|
|
589
|
+
`IndexCoverageReport`, and `Budgets`
|
|
590
|
+
- Attachments: input/reference/owner/range/availability/deletion/reconciliation
|
|
591
|
+
types
|
|
592
|
+
- Diagnostics: `StorageError`, `DiagnosticCodes`, `DiagnosticCode`, `isCode`,
|
|
593
|
+
`isStorageError`, `setDiagnosticLogger`, `DiagnosticLogger`
|
|
594
|
+
|
|
595
|
+
Tests import `createMemoryAdapter`, reset/seed/fault helpers, and the conformance
|
|
596
|
+
suite only from `@chipmobilesdk/rn-local-db/testing`. Other package paths are
|
|
597
|
+
private and unsupported.
|
|
598
|
+
|
|
599
|
+
---
|
|
600
|
+
|
|
601
|
+
## Testing against the in-memory adapter
|
|
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
|
+
|
|
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.
|
|
712
|
+
|
|
713
|
+
---
|
|
714
|
+
|
|
715
|
+
## Demo and validation
|
|
716
|
+
|
|
717
|
+
The demo app exposes ten `src/screens/LocalDb*Screen.tsx` scenarios and uses
|
|
718
|
+
the testing entrypoint's memory adapter so the UI can run without native
|
|
719
|
+
database setup. Run from the repository root:
|
|
720
|
+
|
|
721
|
+
```bash
|
|
722
|
+
npm run typecheck --workspace @chipmobilesdk/rn-local-db
|
|
723
|
+
npm run pack:check --workspace @chipmobilesdk/rn-local-db
|
|
724
|
+
npm run validate
|
|
725
|
+
```
|
|
726
|
+
|
|
727
|
+
Physical-device SQLCipher, backup, corruption, and large-artifact evidence are
|
|
728
|
+
separate release gates; the memory demo does not prove those native behaviors.
|