@chipmobilesdk/rn-local-db 0.3.0 → 0.4.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 CHANGED
@@ -1,5 +1,27 @@
1
1
  # @chipmobilesdk/rn-local-db
2
2
 
3
+ ## 0.4.0
4
+
5
+ ### Minor Changes
6
+
7
+ - Opening a database whose declared schema has not changed now runs no DDL: the applied schema is recorded and compared, instead of re-running CREATE TABLE, PRAGMA table_info and CREATE INDEX for every collection on each start. Index changes take effect — adding `unique`, changing the fields of an index, or removing its declaration used to do nothing. A newly declared field is backfilled the way a write projects it, so an object or array value is NULL for old records as for new ones. ([#19](https://github.com/operationd8979/chip-mobile-sdk/pull/19))
8
+
9
+ Writes make fewer round-trips. `insert` is one statement and reports `RECORD_CONFLICT` without writing, `upsert` on an existing record is one statement, and a batch reads the state of everything it touches once per collection rather than once per operation. A record is serialized once per write, and its size is measured without allocating a copy.
10
+
11
+ - `deleteScopeData`, `resetScope` and `deleteDatabase` now close the connection of any handle still open on what they delete and give back its slot in the open-handle budget. The handle keeps reporting `SCOPE_DELETED`; the app no longer has to close a handle it cannot use, and eight sign-outs without a `close()` no longer end in `OPEN_FAILED`. ([#19](https://github.com/operationd8979/chip-mobile-sdk/pull/19))
12
+
13
+ A database file laid out by a newer release of the package is refused with `SCHEMA_DOWNGRADE` instead of being opened. `setStorageAdapter` is exported from `./testing`, so a test can point `openDatabase` at the in-memory adapter. `MigrationContext.execute` types its parameters as `SqlParameter[]` (string, number, boolean or null) rather than `unknown[]`.
14
+
15
+ Internal: one builder for INSERT and UPDATE statements shared by the collection API and batches, one definition of a valid migration chain, the handle split out of the open path, and unused code removed.
16
+
17
+ - `attachments().write()` accepts `sourcePath` in place of `content`. The file is copied natively when the database is unencrypted, and read and encrypted 256 KB at a time when it is, so writing a large photo or recording no longer needs the whole file in JS memory as base64. `content` keeps working and is still decoded whole. The in-memory adapter takes a `readSourceFile` option for tests that write by path. The README, the changelog and the contract comment no longer claim that write memory was flat before this. ([#19](https://github.com/operationd8979/chip-mobile-sdk/pull/19))
18
+
19
+ ### Patch Changes
20
+
21
+ - Internal: no module in these packages is over 500 lines any more. The camera controller's session I/O moved to `sessionRuntime` and its contract to `contract.ts`; the Play adapter's shape translation to `iapShapes`; the purchase flow's failure mapping and its receipt-validation hook to modules of their own; the AI client's quota request to `quotaRequest`; the in-memory adapter's attachments and the fake ads engine's clock likewise. Behaviour is unchanged — the splits are by responsibility, and every package's suite passes as it stood. ([#19](https://github.com/operationd8979/chip-mobile-sdk/pull/19))
22
+
23
+ The repository also measures what it promised: `npm run perf` prints the median of nine runs of the deterministic hot paths against written budgets (recurrence expansion, theme construction, translation lookup, chart geometry, filter compilation), the demo app has a Performance screen reporting startup phases, time to interactive and the same benchmarks on Hermes, and `npm run check:file-size` keeps the limit from drifting. The budgets and the measured figures are recorded in `docs/standards.md` §VI.
24
+
3
25
  ## 0.3.0
4
26
 
5
27
  ### Minor Changes
@@ -154,7 +176,7 @@ First release. New package, no existing consumers, no migration burden.
154
176
  - **Migrations.** Ascending, at most once per scope, one transaction each, with a per-scope ledger. A failing chain returns no handle and alters nothing; a downgrade is refused rather than truncating. `resetScope` is the separately named recovery path.
155
177
  - **Deletion lifecycle.** `deleteScopeData`, `deleteDatabase`, and `resetScope` as module-level functions that work with or without an open handle. Nothing is ever deleted unasked — no expiry, no eviction, no sweep on close or upgrade. Every deletion reports what it removed and what it could not.
156
178
  - **Attachments.** Package-owned artifacts with availability checks that never open the file, owner-scoped listing, opt-in cascade on record deletion, and app-triggered reconciliation for orphaned files and dangling references. Every filesystem operation is confined to the managed storage area.
157
- - **Encryption at rest.** Opt-in under an app-supplied key. SQLCipher for records; chunked AES-256-GCM with HKDF-derived per-artifact subkeys for files, one 256 KB chunk in flight so memory is flat in artifact size. The package never generates, persists, caches beyond the handle, returns, or logs key material, and depends on no keychain library.
179
+ - **Encryption at rest.** Opt-in under an app-supplied key. SQLCipher for records; chunked AES-256-GCM with HKDF-derived per-artifact subkeys for files, one 256 KB chunk in flight. (Write memory was not in fact flat until `sourcePath` arrived in 0.4.0: a base64 `content` is decoded whole.) The package never generates, persists, caches beyond the handle, returns, or logs key material, and depends on no keychain library.
158
180
  - **Backup posture.** Excluded by default. Enforced on iOS via `NSURLIsExcludedFromBackupKey`; verified and reported on Android, where participation is manifest-owned and unreachable from a library that authors no native code.
159
181
  - **Typed diagnostics.** Twenty-nine stable `DiagnosticCode` values with `isCode` so no consumer parses a message. Disk-full, filesystem-access, and corruption stay distinct. Redaction happens at construction, so no error carries a payload, query parameter, scope component, or key material in any build.
160
182
  - **`./testing` entrypoint.** In-memory adapter, the conformance suite both adapters must pass identically, deterministic reset, seeding through production write paths, and fault injection for every diagnostic code.
package/README.md CHANGED
@@ -356,7 +356,7 @@ Every one returns a `DeletionOutcome` rather than a bare success — what was re
356
356
 
357
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
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.
359
+ A handle whose scope is deleted beneath it reports `SCOPE_DELETED` on its next use. The deletion does not wait for it: it closes that handle's connection and gives back its slot in the open-handle budget, so the app does not have to close a handle it can no longer use. 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
360
 
361
361
  ---
362
362
 
@@ -381,10 +381,25 @@ await attachments.listFor({ collection: 'notes', id: 'n1' });
381
381
  await attachments.remove('photo-1');
382
382
  ```
383
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.
384
+ For anything large, pass the file instead of its content:
385
+
386
+ ```ts
387
+ await attachments.write({
388
+ id: 'video-1',
389
+ contentType: 'video/mp4',
390
+ sourcePath: recording.path, // absolute path; a file:// prefix is accepted
391
+ });
392
+ ```
393
+
394
+ With `sourcePath` the bytes never pass through JS as a whole: an unencrypted
395
+ artifact is one native file copy, and an encrypted one is read and encrypted
396
+ 256 KB at a time, so write memory does not grow with the file. The source file
397
+ is left where it is. `content` is one complete base64 string, held by the app
398
+ and decoded in one piece — fine for a thumbnail, several times the file's size
399
+ in memory for a video. Use range reads to avoid loading an entire stored
400
+ artifact when consuming it; a read without a range returns the whole artifact
401
+ as one string. The in-memory adapter has no filesystem: give it
402
+ `createMemoryAdapter({ readSourceFile })` to test a write by path.
388
403
 
389
404
  **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
405
 
@@ -444,10 +459,9 @@ The provider receives the resolved scope so you can key per identity — which i
444
459
 
445
460
  **Records** are protected by SQLCipher. **Artifacts** use chunked AES-256-GCM:
446
461
  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.
462
+ tag per chunk. Encryption and range decryption operate by chunk. A write from
463
+ `sourcePath` holds one chunk at a time; a write from base64 `content` first
464
+ decodes the complete input, so its memory grows with the artifact.
451
465
 
452
466
  A key-provider failure while the device is locked is an **expected condition**, not a bug:
453
467
 
@@ -601,7 +615,11 @@ private and unsupported.
601
615
  ## Testing against the in-memory adapter
602
616
 
603
617
  ```ts
604
- import { createMemoryAdapter, resetMemoryAdapter } from '@chipmobilesdk/rn-local-db/testing';
618
+ import {
619
+ createMemoryAdapter,
620
+ resetMemoryAdapter,
621
+ setStorageAdapter,
622
+ } from '@chipmobilesdk/rn-local-db/testing';
605
623
 
606
624
  const adapter = createMemoryAdapter();
607
625
  beforeEach(() => resetMemoryAdapter(adapter));
@@ -639,7 +657,7 @@ runConformanceSuite(() => myAdapter, reset, hooks);
639
657
 
640
658
  ### Documented divergences
641
659
 
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:
660
+ The in-memory adapter is faithful, not identical. These seven differences are known, deliberate, and the complete list — anything else that differs is a bug:
643
661
 
644
662
  | Divergence | Why |
645
663
  |---|---|
@@ -649,6 +667,7 @@ The in-memory adapter is faithful, not identical. These six differences are know
649
667
  | **Backup posture**: reports the requested value | There is no filesystem to set an attribute on and no manifest to verify. |
650
668
  | **Artifact chunking**: content is stored whole | Chunk boundaries, nonces, and authentication tags are exercised by the cipher's own tests. |
651
669
  | **Corruption and disk-full**: not spontaneously reachable | Both are device conditions. Use `injectFault` to reach the codes. |
670
+ | **Attachments by `sourcePath`**: read through `readSourceFile`, an option of `createMemoryAdapter` | There is no filesystem to read the path from. Without the option the write is refused with `STORAGE_ACCESS`. |
652
671
 
653
672
  ---
654
673
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chipmobilesdk/rn-local-db",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "private": false,
5
5
  "license": "UNLICENSED",
6
6
  "main": "src/index.ts",
@@ -41,7 +41,7 @@ section, not by deleting the database file.
41
41
  `deleteScopeData` and `deleteDatabase` are module-level and do not wait for open
42
42
  handles. **After either, open a NEW handle and rebuild everything derived from the
43
43
  old one** — repositories, providers, the screen behind a retry button. The old handle
44
- raises `SCOPE_DELETED` (or `CLOSED_HANDLE` if the app closed it first) on every call,
44
+ is closed for you (its connection and handle slot are released) and raises `SCOPE_DELETED` (or `CLOSED_HANDLE` if the app closed it first) on every call,
45
45
  so a Retry wired to it can never succeed: the app stays broken until the process
46
46
  restarts. An interrupted deletion instead reports `SCOPE_DELETED` from the next
47
47
  `openDatabase` — call `deleteScopeData` again to finish it, then open.
@@ -13,7 +13,6 @@
13
13
 
14
14
  import { Budgets } from '../contract/config';
15
15
  import { DiagnosticCodes } from '../contract/errors';
16
- import type { StorableRecordData } from '../contract/record';
17
16
  import { raise } from '../diagnostics/errors';
18
17
  import {
19
18
  assertArtifactWithinBudget,
@@ -53,15 +52,7 @@ export function releaseHandleSlot(): void {
53
52
  openHandles = Math.max(0, openHandles - 1);
54
53
  }
55
54
 
56
- export function openHandleCount(): number {
57
- return openHandles;
58
- }
59
-
60
- /** Between tests, and after a `deleteDatabase` that invalidated every handle. */
55
+ /** Between tests. */
61
56
  export function resetHandleSlots(): void {
62
57
  openHandles = 0;
63
58
  }
64
-
65
- export function assertWritable(collection: string, data: StorableRecordData): void {
66
- assertRecordWithinBudget(collection, data);
67
- }
@@ -27,18 +27,12 @@ import { decodeCursor } from '../query/cursor';
27
27
  import { buildPage, fetchLimitFor, resolvePageSize } from '../query/paginate';
28
28
  import { withStableTiebreaker } from '../query/sort';
29
29
  import { validateQuery } from '../query/validate';
30
- import { collectionTable, hasSoftDelete, hasTimestamps } from '../schema/declare';
30
+ import { collectionTable, hasSoftDelete } from '../schema/declare';
31
31
  import type { Executor } from '../sqlite/connection';
32
32
  import type { SqlValue } from '../sqlite/native';
33
- import {
34
- decodeRow,
35
- encodePayload,
36
- insertColumns,
37
- projectedColumnNames,
38
- projectedValues,
39
- updateAssignments,
40
- } from '../sqlite/row-codec';
33
+ import { decodeRow } from '../sqlite/row-codec';
41
34
  import { buildFilter, buildKeysetPredicate, buildOrderBy } from '../sqlite/sql-builder';
35
+ import { insertStatement, updateStatement } from '../sqlite/statements';
42
36
  import { assertRecordWithinBudget } from './budgets';
43
37
 
44
38
  export type SqlCollectionContext = {
@@ -83,44 +77,35 @@ export function createSqlCollection<T extends StorableRecordData>(
83
77
  async insert(record: RecordInput<T>): Promise<WriteOutcome> {
84
78
  context.assertUsable();
85
79
  throwIfFaulted('write');
86
- assertRecordWithinBudget(schema.name, record.data);
80
+ const payload = assertRecordWithinBudget(schema.name, record.data);
87
81
 
88
- // Checked rather than left to the primary-key constraint: the
89
- // constraint would work, but inside a transaction the engine's
90
- // error can arrive after other statements have run, and reporting
91
- // the conflict before touching anything keeps the failure boring.
92
- if (await rowById(record.id, true)) {
82
+ // One statement: a row that is already there is left untouched and
83
+ // reported, instead of a SELECT first and an INSERT after (LDB-08). The
84
+ // failure stays boring — nothing was written, and no engine error
85
+ // surfaces from inside a transaction.
86
+ const result = await context
87
+ .executor()
88
+ .execute(
89
+ ...insertStatement(schema, record.id, record.data, context.now(), payload, 'ignore'),
90
+ );
91
+ if (result.rowsAffected === 0) {
93
92
  raise(DiagnosticCodes.RECORD_CONFLICT, {
94
93
  message: 'record already exists',
95
94
  detail: { collection: schema.name, recordId: record.id },
96
95
  });
97
96
  }
98
-
99
- const { columns, values } = insertColumns(schema, record.id, record.data, context.now());
100
- await context
101
- .executor()
102
- .execute(
103
- `INSERT INTO ${table} (${columns.map(name => `"${name}"`).join(', ')}) VALUES (${columns
104
- .map(() => '?')
105
- .join(', ')})`,
106
- values,
107
- );
108
97
  return { operation: 'created', id: record.id };
109
98
  },
110
99
 
111
100
  async update(id, record): Promise<WriteOutcome> {
112
101
  context.assertUsable();
113
102
  throwIfFaulted('write');
114
- assertRecordWithinBudget(schema.name, record.data);
103
+ const payload = assertRecordWithinBudget(schema.name, record.data);
115
104
 
116
- const { columns, values } = updateAssignments(schema, record.data, context.now());
117
105
  const result = await context
118
106
  .executor()
119
107
  .execute(
120
- `UPDATE ${table} SET ${columns
121
- .map(name => `"${name}" = ?`)
122
- .join(', ')} WHERE id = ? AND ${LIVE}`,
123
- [...values, id],
108
+ ...updateStatement(schema, id, record.data, context.now(), payload, { liveOnly: true }),
124
109
  );
125
110
  if (result.rowsAffected === 0) {
126
111
  // Creating the record instead would be a silent write to an id
@@ -133,47 +118,34 @@ export function createSqlCollection<T extends StorableRecordData>(
133
118
  async upsert(record: RecordInput<T>): Promise<WriteOutcome> {
134
119
  context.assertUsable();
135
120
  throwIfFaulted('write');
136
- assertRecordWithinBudget(schema.name, record.data);
137
-
138
- const existed = (await rowById(record.id, true)) !== undefined;
121
+ const payload = assertRecordWithinBudget(schema.name, record.data);
139
122
  const now = context.now();
140
123
 
141
- if (existed) {
142
- const assignments = ['payload = ?'];
143
- const values: SqlValue[] = [encodePayload(record.data)];
144
- if (hasTimestamps(schema)) {
145
- assignments.push('updated_at = ?');
146
- values.push(now);
147
- }
148
- if (hasSoftDelete(schema)) {
149
- // An upsert over a soft-deleted record brings it back: the
150
- // caller asked for this id to hold this data.
151
- assignments.push('deleted_at = NULL');
152
- }
153
- const projected = projectedValues(schema, record.data);
154
- for (const [index, name] of projectedColumnNames(schema).entries()) {
155
- assignments.push(`"${name}" = ?`);
156
- values.push(projected[index]!);
157
- }
158
- await context
124
+ // An upsert over a soft-deleted record brings it back: the caller
125
+ // asked for this id to hold this data.
126
+ const update = async (): Promise<boolean> => {
127
+ const result = await context
159
128
  .executor()
160
- .execute(`UPDATE ${table} SET ${assignments.join(', ')} WHERE id = ?`, [
161
- ...values,
162
- record.id,
163
- ]);
129
+ .execute(
130
+ ...updateStatement(schema, record.id, record.data, now, payload, { restore: true }),
131
+ );
132
+ return result.rowsAffected > 0;
133
+ };
134
+
135
+ // The update is tried first: one round-trip for a record that exists,
136
+ // with no SELECT before it (LDB-08).
137
+ if (await update()) {
164
138
  return { operation: 'updated', id: record.id };
165
139
  }
166
-
167
- const { columns, values } = insertColumns(schema, record.id, record.data, now);
168
- await context
140
+ const inserted = await context
169
141
  .executor()
170
- .execute(
171
- `INSERT INTO ${table} (${columns.map(name => `"${name}"`).join(', ')}) VALUES (${columns
172
- .map(() => '?')
173
- .join(', ')})`,
174
- values,
175
- );
176
- return { operation: 'created', id: record.id };
142
+ .execute(...insertStatement(schema, record.id, record.data, now, payload, 'ignore'));
143
+ if (inserted.rowsAffected > 0) {
144
+ return { operation: 'created', id: record.id };
145
+ }
146
+ // Another write created the row between the two statements.
147
+ await update();
148
+ return { operation: 'updated', id: record.id };
177
149
  },
178
150
 
179
151
  async get(id: string): Promise<StoredRecord<T>> {
@@ -60,6 +60,51 @@ export function isScopeInvalidated(name: string, scopeKey: string): boolean {
60
60
 
61
61
  export function clearAllInvalidations(): void {
62
62
  invalidated.clear();
63
+ openHandles.clear();
64
+ }
65
+
66
+ /**
67
+ * Handles open on a scope, so a deletion can take their connection away
68
+ * (LDB-14). Marking alone left the native connection and its handle slot held
69
+ * until the app closed a handle it had been told was dead: eight sign-outs
70
+ * later every open failed, with the unlinked file still pinned by its
71
+ * descriptor.
72
+ */
73
+ const openHandles = new Map<string, Set<() => Promise<void>>>();
74
+
75
+ /** Returns the function that takes the handle out of the registry again. */
76
+ export function registerOpenHandle(
77
+ name: string,
78
+ scopeKey: string,
79
+ revoke: () => Promise<void>,
80
+ ): () => void {
81
+ const key = invalidationKey(name, scopeKey);
82
+ const handles = openHandles.get(key) ?? new Set();
83
+ handles.add(revoke);
84
+ openHandles.set(key, handles);
85
+ return () => {
86
+ handles.delete(revoke);
87
+ if (handles.size === 0) {
88
+ openHandles.delete(key);
89
+ }
90
+ };
91
+ }
92
+
93
+ /** Every handle on the scope, or on any scope of the database when none is named. */
94
+ async function revokeHandles(name: string, scopeKey?: string): Promise<void> {
95
+ const prefix = `${name}:`;
96
+ for (const [key, handles] of [...openHandles]) {
97
+ const matches =
98
+ scopeKey === undefined ? key.startsWith(prefix) : key === invalidationKey(name, scopeKey);
99
+ if (!matches) {
100
+ continue;
101
+ }
102
+ openHandles.delete(key);
103
+ for (const revoke of handles) {
104
+ // A connection that will not close does not get to stop a deletion.
105
+ await revoke().catch(() => undefined);
106
+ }
107
+ }
63
108
  }
64
109
 
65
110
  /** Deleting a scope that holds nothing is success, not an error (FR-062). */
@@ -144,6 +189,8 @@ async function removeScope(config: ScopeTargetConfig): Promise<DeletionOutcome>
144
189
  await markDeleting(catalog, scope.key);
145
190
  }
146
191
  markScopeInvalidated(config.name, scope.key);
192
+ // Before the files go: a connection still open on them pins them.
193
+ await revokeHandles(config.name, scope.key);
147
194
 
148
195
  const removedArtifacts = await artifactIdsOnDisk(layout, scope.key);
149
196
 
@@ -241,6 +288,7 @@ export async function deleteDatabase(config: DatabaseTargetConfig): Promise<Dele
241
288
  for (const scopeKey of scopeKeys) {
242
289
  markScopeInvalidated(config.name, scopeKey);
243
290
  }
291
+ await revokeHandles(config.name);
244
292
 
245
293
  const removed = await removeIfPresent(layout.root);
246
294
  return {