@chipmobilesdk/rn-local-db 0.1.1 → 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 +50 -0
- package/README.md +75 -73
- package/package.json +1 -1
- package/skills/sdk-rn-local-db/SKILL.md +11 -3
- package/src/adapter/deletion.ts +24 -10
- package/src/adapter/lifecycle.ts +121 -45
- package/src/adapter/transaction.ts +6 -0
- package/src/artifacts/availability.ts +3 -5
- package/src/artifacts/cascade.ts +42 -6
- package/src/artifacts/store.ts +34 -12
- package/src/contract/attachment.ts +6 -0
- package/src/contract/migration.ts +3 -1
- package/src/contract/record.ts +9 -0
- package/src/contract/transaction.ts +8 -0
- package/src/crypto/chunked-cipher.ts +28 -22
- package/src/crypto/derive.ts +2 -1
- package/src/platform/bytes.ts +144 -0
- package/src/query/budgets.ts +2 -5
- package/src/query/cursor.ts +3 -2
- package/src/sqlite/batch.ts +28 -18
- package/src/sqlite/connection.ts +66 -44
- package/src/sqlite/index-coverage.ts +4 -1
- package/src/sqlite/row-codec.ts +23 -3
- package/src/sqlite/transaction-queue.ts +87 -0
- package/src/testing/conformance/attachments.ts +1 -1
- package/src/testing/memory-adapter/index.ts +176 -72
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.
|
|
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
|
|
@@ -354,7 +356,7 @@ Every one returns a `DeletionOutcome` rather than a bare success — what was re
|
|
|
354
356
|
|
|
355
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.
|
|
356
358
|
|
|
357
|
-
A handle whose scope is deleted beneath it reports `SCOPE_DELETED` on its next use. The deletion does not wait for it.
|
|
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.
|
|
358
360
|
|
|
359
361
|
---
|
|
360
362
|
|
|
@@ -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: sdk-rn-local-db
|
|
3
|
-
description: "Offline SQLite storage via @chipmobilesdk/rn-local-db: schemas, scoped data, queries, transactions, migrations, attachments, encryption. Use when persisting data locally, writing a migration, or handling sign-out data retention."
|
|
3
|
+
description: "Offline SQLite storage via @chipmobilesdk/rn-local-db: schemas, scoped data, queries, transactions, migrations, attachments, encryption. Use when persisting data locally, writing a migration, deleting a user's data, or handling sign-out data retention."
|
|
4
4
|
metadata:
|
|
5
5
|
package: "@chipmobilesdk/rn-local-db"
|
|
6
6
|
packageVersion: "0.1.0"
|
|
@@ -33,12 +33,20 @@ Two concepts cause most of the bugs in this package. Do not skip them:
|
|
|
33
33
|
Read `## The scope model` and `## Records and the four value conditions` in the README
|
|
34
34
|
before designing a schema.
|
|
35
35
|
|
|
36
|
-
##
|
|
36
|
+
## Deletion, sign-out, and dead handles
|
|
37
37
|
|
|
38
38
|
The README has a dedicated section (`## Deletion, and why sign-out is not deletion`).
|
|
39
39
|
Treat any "clear data on logout" requirement as a design question answered by that
|
|
40
40
|
section, not by deleting the database file.
|
|
41
41
|
|
|
42
|
+
`deleteScopeData` and `deleteDatabase` are module-level and do not wait for open
|
|
43
|
+
handles. **After either, open a NEW handle and rebuild everything derived from the
|
|
44
|
+
old one** — repositories, providers, the screen behind a retry button. The old handle
|
|
45
|
+
raises `SCOPE_DELETED` (or `CLOSED_HANDLE` if the app closed it first) on every call,
|
|
46
|
+
so a Retry wired to it can never succeed: the app stays broken until the process
|
|
47
|
+
restarts. An interrupted deletion instead reports `SCOPE_DELETED` from the next
|
|
48
|
+
`openDatabase` — call `deleteScopeData` again to finish it, then open.
|
|
49
|
+
|
|
42
50
|
## Public API
|
|
43
51
|
|
|
44
52
|
`setDiagnosticLogger`, `Budgets`, and types `DiagnosticLogger`, `DiagnosticCode` are
|
|
@@ -53,7 +61,7 @@ surfaces are large — enumerate them from
|
|
|
53
61
|
| Need | Section |
|
|
54
62
|
|---|---|
|
|
55
63
|
| Install and native setup | `## Install` |
|
|
56
|
-
| Data ownership / sign-out | `## The scope model`, `## Deletion, and why sign-out is not deletion` |
|
|
64
|
+
| Data ownership / sign-out / deleting it all | `## The scope model`, `## Deletion, and why sign-out is not deletion` |
|
|
57
65
|
| Defining tables | `## Declaring a schema` |
|
|
58
66
|
| Null vs missing vs unset | `## Records and the four value conditions` |
|
|
59
67
|
| Reading data | `## Queries` |
|
package/src/adapter/deletion.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
let
|
|
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
|
-
|
|
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,
|
package/src/adapter/lifecycle.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
311
|
-
|
|
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>(
|
|
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: () =>
|
|
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
|
|
341
|
-
|
|
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(
|
|
350
|
-
|
|
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
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
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
|
-
|
|
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:
|
|
395
|
-
|
|
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
|
|
443
|
+
const value = await fn(managed.transaction);
|
|
398
444
|
if (!managed.isSettled()) {
|
|
399
445
|
throwIfFaulted('commit');
|
|
400
446
|
}
|
|
401
447
|
managed.settle();
|
|
402
|
-
return
|
|
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
|
-
|
|
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;
|