@chipmobilesdk/rn-local-db 0.1.2 → 0.2.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
@@ -2,6 +2,56 @@
2
2
 
3
3
  All notable changes to `@chipmobilesdk/rn-local-db` are recorded here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this package follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
4
4
 
5
+ ## [0.2.0] — 2026-11-01
6
+
7
+ Fixes a data-loss defect in transactions and attachment handling that failed on every
8
+ Hermes device. **One behaviour change needs a code change** (see Changed).
9
+
10
+ ### Fixed
11
+
12
+ - **A write from outside a transaction is no longer lost with its rollback.** The
13
+ connection routed every statement into whatever transaction was in flight, so a write
14
+ from another screen or a background sync while a transaction ran was rolled back with
15
+ it, and a reader outside saw uncommitted data. Each handle now queues work behind a
16
+ running transaction; only the executor the transaction hands out reaches it. The
17
+ memory adapter isolates the same way, so conformance now catches a regression. After
18
+ `tx.abort()` the body's remaining statements are refused instead of running in
19
+ autocommit.
20
+ - **Attachments work on Hermes.** Writes and reads used Node's global `Buffer`, which
21
+ Hermes does not have. Byte handling is now `Uint8Array` with a self-contained base64
22
+ codec; the encrypted file format is unchanged.
23
+ - **A batch upsert over a soft-deleted record restores it**, and a batch insert over one
24
+ conflicts — the collection API's rules. The batch used to issue an INSERT and fail the
25
+ whole batch on the primary key.
26
+ - **Index names are scoped to their collection.** Two collections declaring an index of
27
+ the same name used to get only the first one, a UNIQUE constraint included. Existing
28
+ indexes are renamed on the next open; no data moves. `explain()` still reports the
29
+ declared name.
30
+ - **Deleting an encrypted scope no longer needs its key.** A failing or destroyed key
31
+ failed the call after the scope was marked, leaving it stuck in `deleting`. The data is
32
+ deleted and the outcome sets `recordCountUnavailable`.
33
+ - **Cascaded attachment removal is transaction-safe and reported.** Inside a
34
+ transaction, files are removed only after it commits, so a rollback no longer leaves a
35
+ reference to a deleted file. The reference is deleted before the file, a file that
36
+ could not be removed is reported in `failedRemovals`, batch deletes cascade, and the
37
+ memory adapter implements cascading.
38
+
39
+ ### Changed
40
+
41
+ - **Needs a code change:** inside a transaction body, write attachments through
42
+ `tx.attachments()` (new) instead of `handle.attachments()`. The handle's call now waits
43
+ for the transaction; awaited from inside the body it is refused with
44
+ `TRANSACTION_STATE` after 10 seconds and the transaction rolls back, instead of
45
+ hanging. The same applies to `handle.collection()`, which inside a transaction was
46
+ already documented as a mistake.
47
+ - `MigrationContext` no longer includes `attachments()`.
48
+
49
+ ### Added
50
+
51
+ - `Transaction.attachments()`.
52
+ - `WriteOutcome.cascade` on a delete when `cascadeAttachments` is on.
53
+ - `DeletionOutcome.recordCountUnavailable`.
54
+
5
55
  ## [0.1.0] — 2026-08-01
6
56
 
7
57
  First release. New package, no existing consumers, no migration burden.
package/README.md CHANGED
@@ -1,13 +1,13 @@
1
1
  # @chipmobilesdk/rn-local-db
2
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**: `0.1.0`
9
- - **Entrypoints**: `@chipmobilesdk/rn-local-db` and the test-only
10
- `@chipmobilesdk/rn-local-db/testing`
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**: `0.2.0`
9
+ - **Entrypoints**: `@chipmobilesdk/rn-local-db` and the test-only
10
+ `@chipmobilesdk/rn-local-db/testing`
11
11
 
12
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
13
 
@@ -28,9 +28,9 @@ Structured records, queries a list screen can actually use, transactions with re
28
28
  - [Attachments](#attachments)
29
29
  - [Encryption](#encryption)
30
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)
31
+ - [Diagnostics](#diagnostics)
32
+ - [Public API](#public-api)
33
+ - [Testing against the in-memory adapter](#testing-against-the-in-memory-adapter)
34
34
  - [Budgets](#budgets)
35
35
  - [Compatibility](#compatibility)
36
36
  - [What this package stores, and where](#what-this-package-stores-and-where)
@@ -42,8 +42,8 @@ Structured records, queries a list screen can actually use, transactions with re
42
42
  ```sh
43
43
  npm install @chipmobilesdk/rn-local-db
44
44
  npm install @op-engineering/op-sqlite @dr.pogodin/react-native-fs
45
- # Only if you will enable encryption:
46
- npm install react-native-quick-crypto
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
47
  cd ios && pod install
48
48
  ```
49
49
 
@@ -264,6 +264,8 @@ await handle.transaction(async tx => {
264
264
 
265
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
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
+
267
269
  A transaction never spans two scopes — with one database file per scope, that is a type error rather than a runtime check.
268
270
 
269
271
  ```ts
@@ -376,13 +378,13 @@ await attachments.read('photo-1'); // whole
376
378
  await attachments.read('photo-1', { offset: 0, length: 4096 }); // a range
377
379
  await attachments.availability('photo-1'); // never opens the file
378
380
  await attachments.listFor({ collection: 'notes', id: 'n1' });
379
- await attachments.remove('photo-1');
380
- ```
381
-
382
- `AttachmentInput.content` is one complete base64 string. The current public
383
- write contract therefore requires the app to hold the encoded payload, and the
384
- native adapter decodes that full string before processing encrypted chunks.
385
- Use range reads to avoid loading an entire stored artifact when consuming it.
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.
386
388
 
387
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.
388
390
 
@@ -440,12 +442,12 @@ const handle = await openDatabase({
440
442
 
441
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.
442
444
 
443
- **Records** are protected by SQLCipher. **Artifacts** use chunked AES-256-GCM:
444
- 256 KB chunks, a per-artifact subkey derived with HKDF, and an authentication
445
- tag per chunk. Encryption and range decryption operate by chunk, but the
446
- current write API first decodes the complete base64 input. Peak write memory is
447
- therefore not flat in artifact size; size large media before passing it to this
448
- version.
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.
449
451
 
450
452
  A key-provider failure while the device is locked is an **expected condition**, not a bug:
451
453
 
@@ -570,33 +572,33 @@ try {
570
572
  setDiagnosticLogger(event => console.log(event.operation, event.durationMs, event.count));
571
573
  ```
572
574
 
573
- Operation names, durations, counts, and outcome codes. There is deliberately no path that accepts a payload, and the logger is inert in production builds.
574
-
575
- ---
576
-
577
- ## Public API
578
-
579
- Production code imports only from `@chipmobilesdk/rn-local-db`:
580
-
581
- - Lifecycle: `openDatabase`, `deleteScopeData`, `resetScope`, `deleteDatabase`
582
- - Handle contract: `DatabaseHandle`, `CollectionOps`, `Transaction`,
583
- `Attachments`, `StorageAdapter`, plus batch/transaction result types
584
- - Configuration and isolation: database/target/scope descriptors, collection
585
- schema, migrations, encryption/key provider, and backup posture types
586
- - Records and queries: record/value/write types, filter/sort/page/query types,
587
- `IndexCoverageReport`, and `Budgets`
588
- - Attachments: input/reference/owner/range/availability/deletion/reconciliation
589
- types
590
- - Diagnostics: `StorageError`, `DiagnosticCodes`, `DiagnosticCode`, `isCode`,
591
- `isStorageError`, `setDiagnosticLogger`, `DiagnosticLogger`
592
-
593
- Tests import `createMemoryAdapter`, reset/seed/fault helpers, and the conformance
594
- suite only from `@chipmobilesdk/rn-local-db/testing`. Other package paths are
595
- private and unsupported.
596
-
597
- ---
598
-
599
- ## Testing against the in-memory adapter
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
600
602
 
601
603
  ```ts
602
604
  import { createMemoryAdapter, resetMemoryAdapter } from '@chipmobilesdk/rn-local-db/testing';
@@ -680,7 +682,7 @@ Four are **hard limits** with a typed error. The rest are soft: exceeding them i
680
682
  | iOS | 15.1+ |
681
683
  | `@op-engineering/op-sqlite` | >= 17.1.0 |
682
684
  | `@dr.pogodin/react-native-fs` | >= 2.39.2 |
683
- | `react-native-quick-crypto` | >= 1.0.0 — **optional**, only for encryption |
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` |
684
686
 
685
687
  **Storage location**: app-private documents directory, under `chipmobilesdk-localdb/`.
686
688
  **Backup mechanism**: `NSURLIsExcludedFromBackupKey` on iOS; `dataExtractionRules` / `fullBackupContent` on Android.
@@ -706,21 +708,21 @@ Written for the privacy disclosure a consuming app has to make.
706
708
 
707
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.
708
710
 
709
- **Your remaining obligations**: this package collects nothing and declares nothing on your behalf. If your app stores health, financial, or other sensitive categories in it, those categories are yours to declare in App Privacy and Data Safety.
710
-
711
- ---
712
-
713
- ## Demo and validation
714
-
715
- The demo app exposes ten `src/screens/LocalDb*Screen.tsx` scenarios and uses
716
- the testing entrypoint's memory adapter so the UI can run without native
717
- database setup. Run from the repository root:
718
-
719
- ```bash
720
- npm run typecheck:local-db
721
- npm run pack:local-db
722
- npm run validate:local-db
723
- ```
724
-
725
- Physical-device SQLCipher, backup, corruption, and large-artifact evidence are
726
- separate release gates; the memory demo does not prove those native behaviors.
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:local-db
723
+ npm run pack:local-db
724
+ npm run validate:local-db
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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chipmobilesdk/rn-local-db",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "private": false,
5
5
  "license": "UNLICENSED",
6
6
  "main": "src/index.ts",
@@ -85,7 +85,7 @@ async function countRecords(
85
85
  layout: StorageLayout,
86
86
  scopeKey: string,
87
87
  encryptionKey: string | undefined,
88
- ): Promise<number> {
88
+ ): Promise<number | undefined> {
89
89
  let connection: Connection | null = null;
90
90
  try {
91
91
  connection = await openConnection({
@@ -103,7 +103,9 @@ async function countRecords(
103
103
  }
104
104
  return total;
105
105
  } catch {
106
- return 0;
106
+ // Unknown, not zero: a report that said "0 records" about a scope it
107
+ // could not open would understate what was deleted.
108
+ return undefined;
107
109
  } finally {
108
110
  await connection?.close();
109
111
  }
@@ -145,14 +147,25 @@ async function removeScope(config: ScopeTargetConfig): Promise<DeletionOutcome>
145
147
 
146
148
  const removedArtifacts = await artifactIdsOnDisk(layout, scope.key);
147
149
 
148
- // The key is needed to count what is being removed, not to remove it.
149
- // It is released the moment the count is done, whatever happened.
150
- const keyHandle = config.encryption?.enabled
151
- ? await acquireKey(config.encryption, scope)
152
- : null;
153
- let removedRecords: number;
150
+ // The key is needed to count what is being removed, not to remove it,
151
+ // so a key provider that fails — or a key already destroyed for this
152
+ // identity, which FR-104 encourages — costs the count, never the
153
+ // deletion. Until 0.2.0 it failed the whole call after the scope was
154
+ // already marked, leaving it stuck in `deleting`.
155
+ let keyHandle: Awaited<ReturnType<typeof acquireKey>> | null = null;
156
+ if (config.encryption?.enabled) {
157
+ try {
158
+ keyHandle = await acquireKey(config.encryption, scope);
159
+ } catch {
160
+ keyHandle = null;
161
+ }
162
+ }
163
+ let counted: number | undefined;
154
164
  try {
155
- removedRecords = await countRecords(layout, scope.key, keyHandle?.value());
165
+ counted =
166
+ config.encryption?.enabled && keyHandle === null
167
+ ? undefined
168
+ : await countRecords(layout, scope.key, keyHandle?.value());
156
169
  } finally {
157
170
  keyHandle?.release();
158
171
  }
@@ -176,7 +189,8 @@ async function removeScope(config: ScopeTargetConfig): Promise<DeletionOutcome>
176
189
 
177
190
  return {
178
191
  scopeKey: scope.key,
179
- removedRecords,
192
+ removedRecords: counted ?? 0,
193
+ ...(counted === undefined ? { recordCountUnavailable: true } : {}),
180
194
  removedArtifacts,
181
195
  orphanedReferences: [],
182
196
  failedRemovals,
@@ -43,7 +43,7 @@ import type {
43
43
  Transaction,
44
44
  } from '../contract/transaction';
45
45
  import { artifactAvailability } from '../artifacts/availability';
46
- import { cascadeForOwner } from '../artifacts/cascade';
46
+ import { cascadeForOwner, unlinkArtifact, type DeferredUnlinks } from '../artifacts/cascade';
47
47
  import { reconcileArtifacts } from '../artifacts/reconcile';
48
48
  import {
49
49
  ensureAttachmentTable,
@@ -69,10 +69,15 @@ import { validateConfig } from '../schema/validate-config';
69
69
  import { resolveScope } from '../scope/validate';
70
70
  import { runBatch } from '../sqlite/batch';
71
71
  import { ensureCatalog, readEntry, registerScope } from '../sqlite/catalog';
72
- import { assertReadable, openConnection, type Connection } from '../sqlite/connection';
72
+ import { assertReadable, openConnection, type Connection, type Executor } from '../sqlite/connection';
73
73
  import { explainQuery } from '../sqlite/index-coverage';
74
74
  import { isEncryptionAvailable } from '../sqlite/native';
75
- import { addColumnSql, createIndexSql, createTableSql } from '../sqlite/row-codec';
75
+ import {
76
+ addColumnSql,
77
+ createIndexSql,
78
+ createTableSql,
79
+ physicalIndexName,
80
+ } from '../sqlite/row-codec';
76
81
  import { runInTransaction } from '../sqlite/transactions';
77
82
  import { text } from '../sqlite/values';
78
83
  import { acquireHandleSlot, releaseHandleSlot } from './budgets';
@@ -114,6 +119,17 @@ async function ensureCollection(
114
119
  );
115
120
  }
116
121
 
122
+ // Indexes created before 0.2.0 carry the bare declared name. Each one still
123
+ // attached to this table is dropped and recreated under its collection-scoped
124
+ // name; only indexes on this table are touched, and no row data moves.
125
+ const existing = await connection.execute(`PRAGMA index_list("c_${schema.name}")`);
126
+ const onTable = new Set(existing.rows.map(row => text(row.name)));
127
+ for (const index of schema.indexes ?? []) {
128
+ if (onTable.has(index.name) && index.name !== physicalIndexName(schema, index.name)) {
129
+ await connection.execute(`DROP INDEX IF EXISTS "${index.name}"`);
130
+ }
131
+ }
132
+
117
133
  for (const statement of createIndexSql(schema)) {
118
134
  await connection.execute(statement);
119
135
  }
@@ -307,22 +323,29 @@ function buildHandle(context: HandleContext): DatabaseHandle {
307
323
  }
308
324
  };
309
325
 
310
- const artifacts: ArtifactContext = {
311
- executor: () => connection,
326
+ // Everything below is built against an explicit executor: the connection for
327
+ // the handle, the transaction's own executor inside `transaction`. Nothing
328
+ // infers which one from the connection's state, which is how a write from
329
+ // outside used to be swallowed by an in-flight transaction (LDB-01).
330
+ const artifactsOn = (executor: Executor): ArtifactContext => ({
331
+ executor: () => executor,
312
332
  layout,
313
333
  scopeKey: scope.key,
314
334
  artifactKey: attachmentId =>
315
335
  context.key ? deriveArtifactKey(context.key.value(), scope.key, attachmentId) : null,
316
336
  now: context.now,
317
- };
337
+ });
318
338
 
319
- const rawCollection = <T extends StorableRecordData>(name: string): CollectionOps<T> => {
339
+ const rawCollection = <T extends StorableRecordData>(
340
+ name: string,
341
+ executor: Executor,
342
+ ): CollectionOps<T> => {
320
343
  assertUsable();
321
344
  return instrument(
322
345
  name,
323
346
  'sqlite',
324
347
  createSqlCollection<T>({
325
- executor: () => connection,
348
+ executor: () => executor,
326
349
  schema: schemaFor(schemas, name),
327
350
  now: context.now,
328
351
  assertUsable,
@@ -335,10 +358,14 @@ function buildHandle(context: HandleContext): DatabaseHandle {
335
358
  *
336
359
  * Off unless the app asks: a package that deleted a user's files as a side
337
360
  * effect of a record delete would be making a decision that is not its to
338
- * make.
361
+ * make. The outcome carries what was removed and what could not be.
339
362
  */
340
- const collectionFor = <T extends StorableRecordData>(name: string): CollectionOps<T> => {
341
- const ops = rawCollection<T>(name);
363
+ const collectionOn = <T extends StorableRecordData>(
364
+ name: string,
365
+ executor: Executor,
366
+ deferred?: DeferredUnlinks,
367
+ ): CollectionOps<T> => {
368
+ const ops = rawCollection<T>(name, executor);
342
369
  if (config.cascadeAttachments !== true) {
343
370
  return ops;
344
371
  }
@@ -346,39 +373,51 @@ function buildHandle(context: HandleContext): DatabaseHandle {
346
373
  ...ops,
347
374
  async delete(id: string) {
348
375
  const outcome = await ops.delete(id);
349
- await cascadeForOwner(artifacts, connection, { collection: name, id });
350
- return outcome;
376
+ const cascade = await cascadeForOwner(
377
+ artifactsOn(executor),
378
+ executor,
379
+ { collection: name, id },
380
+ deferred,
381
+ );
382
+ return { ...outcome, cascade };
351
383
  },
352
384
  };
353
385
  };
354
386
 
355
- const attachments: Attachments = {
356
- write(input: AttachmentInput) {
357
- assertUsable();
358
- return writeArtifact(artifacts, input);
359
- },
360
- read(id: string, range?: ByteRange) {
361
- assertUsable();
362
- return readArtifact(artifacts, id, range);
363
- },
364
- availability(id: string) {
365
- assertUsable();
366
- return artifactAvailability(artifacts, connection, id);
367
- },
368
- remove(id: string): Promise<DeletionOutcome> {
369
- assertUsable();
370
- return removeArtifact(artifacts, id);
371
- },
372
- listFor(owner: AttachmentOwner) {
373
- assertUsable();
374
- return listReferencesFor(connection, owner);
375
- },
376
- reconcile(options): Promise<ReconciliationReport> {
377
- assertUsable();
378
- return reconcileArtifacts(artifacts, connection, options);
379
- },
387
+ const attachmentsOn = (executor: Executor, deferred?: DeferredUnlinks): Attachments => {
388
+ const artifacts = artifactsOn(executor);
389
+ return {
390
+ write(input: AttachmentInput) {
391
+ assertUsable();
392
+ return writeArtifact(artifacts, input);
393
+ },
394
+ read(id: string, range?: ByteRange) {
395
+ assertUsable();
396
+ return readArtifact(artifacts, id, range);
397
+ },
398
+ availability(id: string) {
399
+ assertUsable();
400
+ return artifactAvailability(artifacts, executor, id);
401
+ },
402
+ remove(id: string): Promise<DeletionOutcome> {
403
+ assertUsable();
404
+ return removeArtifact(artifacts, id, deferred);
405
+ },
406
+ listFor(owner: AttachmentOwner) {
407
+ assertUsable();
408
+ return listReferencesFor(executor, owner);
409
+ },
410
+ reconcile(options): Promise<ReconciliationReport> {
411
+ assertUsable();
412
+ return reconcileArtifacts(artifacts, executor, options);
413
+ },
414
+ };
380
415
  };
381
416
 
417
+ const collectionFor = <T extends StorableRecordData>(name: string): CollectionOps<T> =>
418
+ collectionOn<T>(name, connection);
419
+ const attachments = attachmentsOn(connection);
420
+
382
421
  const handle: DatabaseHandle = {
383
422
  scopeKey: scope.key,
384
423
  schemaVersion: context.schemaVersion,
@@ -389,23 +428,39 @@ function buildHandle(context: HandleContext): DatabaseHandle {
389
428
 
390
429
  async transaction<R>(fn: (tx: Transaction) => Promise<R>): Promise<R> {
391
430
  assertUsable();
392
- return runInTransaction(connection, async tx => {
431
+ const pendingUnlinks: string[] = [];
432
+ const deferred: DeferredUnlinks = { add: path => pendingUnlinks.push(path) };
433
+ let aborted = false;
434
+ const result = await runInTransaction(connection, async tx => {
393
435
  const managed = createTransaction({
394
- collection: collectionFor,
395
- rollback: () => tx.rollback(),
436
+ collection: name => collectionOn(name, tx, deferred),
437
+ attachments: () => attachmentsOn(tx, deferred),
438
+ rollback: async () => {
439
+ aborted = true;
440
+ await tx.rollback();
441
+ },
396
442
  });
397
- const result = await fn(managed.transaction);
443
+ const value = await fn(managed.transaction);
398
444
  if (!managed.isSettled()) {
399
445
  throwIfFaulted('commit');
400
446
  }
401
447
  managed.settle();
402
- return result;
448
+ return value;
403
449
  });
450
+ // Committed (a thrown body never gets here). Only now are files whose
451
+ // references the transaction deleted safe to remove; one that cannot be
452
+ // removed stays on disk as an orphan reconciliation reports.
453
+ if (!aborted) {
454
+ for (const path of pendingUnlinks) {
455
+ await unlinkArtifact(path);
456
+ }
457
+ }
458
+ return result;
404
459
  },
405
460
 
406
- batch(operations: BatchOperation[]): Promise<BatchOutcome> {
461
+ async batch(operations: BatchOperation[]): Promise<BatchOutcome> {
407
462
  assertUsable();
408
- return runBatch(
463
+ const outcome = await runBatch(
409
464
  {
410
465
  connection,
411
466
  schemaFor: name => schemaFor(schemas, name),
@@ -413,6 +468,27 @@ function buildHandle(context: HandleContext): DatabaseHandle {
413
468
  },
414
469
  operations,
415
470
  );
471
+ if (config.cascadeAttachments !== true) {
472
+ return outcome;
473
+ }
474
+ // The batch has committed, so its hard deletes cascade exactly as a
475
+ // collection delete does. Skipping them left every attachment of a
476
+ // batch-deleted record behind as an orphan.
477
+ const artifacts = artifactsOn(connection);
478
+ const outcomes = [];
479
+ for (const [index, operation] of operations.entries()) {
480
+ const written = outcome.outcomes[index]!;
481
+ if (operation.type !== 'delete') {
482
+ outcomes.push(written);
483
+ continue;
484
+ }
485
+ const cascade = await cascadeForOwner(artifacts, connection, {
486
+ collection: operation.collection,
487
+ id: operation.id,
488
+ });
489
+ outcomes.push({ ...written, cascade });
490
+ }
491
+ return { ...outcome, outcomes };
416
492
  },
417
493
 
418
494
  attachments: () => attachments,
@@ -18,10 +18,12 @@
18
18
  import { DiagnosticCodes } from '../contract/errors';
19
19
  import type { StorableRecordData } from '../contract/record';
20
20
  import type { CollectionOps, Transaction } from '../contract/transaction';
21
+ import type { Attachments } from '../contract/adapter';
21
22
  import { raise } from '../diagnostics/errors';
22
23
 
23
24
  export type TransactionHost = {
24
25
  collection<T extends StorableRecordData>(name: string): CollectionOps<T>;
26
+ attachments(): Attachments;
25
27
  rollback(): Promise<void>;
26
28
  };
27
29
 
@@ -48,6 +50,10 @@ export function createTransaction(host: TransactionHost): ManagedTransaction {
48
50
  assertLive();
49
51
  return host.collection<T>(name);
50
52
  },
53
+ attachments(): Attachments {
54
+ assertLive();
55
+ return host.attachments();
56
+ },
51
57
  async abort(): Promise<void> {
52
58
  assertLive();
53
59
  settled = true;
@@ -15,6 +15,7 @@
15
15
 
16
16
  import type { AttachmentAvailability } from '../contract/attachment';
17
17
  import { fileSystem } from '../platform/fs';
18
+ import { asciiAt, base64ToBytes } from '../platform/bytes';
18
19
  import { HEADER_BYTES, MAGIC } from '../crypto/chunked-cipher';
19
20
  import type { Executor } from '../sqlite/connection';
20
21
  import { artifactPath } from './paths';
@@ -54,11 +55,8 @@ export async function artifactAvailability(
54
55
  }
55
56
 
56
57
  try {
57
- const header = Buffer.from(
58
- await fileSystem().read(path, HEADER_BYTES, 0, 'base64'),
59
- 'base64',
60
- );
61
- if (header.toString('ascii', 0, 4) !== MAGIC) {
58
+ const header = base64ToBytes(await fileSystem().read(path, HEADER_BYTES, 0, 'base64'));
59
+ if (asciiAt(header, 0, 4) !== MAGIC) {
62
60
  return { id, state: 'undecryptable', size: reference.size };
63
61
  }
64
62
  } catch {