@chipmobilesdk/rn-local-db 0.2.1 → 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 +92 -1
- package/README.md +31 -12
- package/package.json +5 -2
- package/skills/sdk-rn-local-db/SKILL.md +1 -1
- package/src/adapter/budgets.ts +1 -10
- package/src/adapter/collection.ts +38 -66
- package/src/adapter/deletion.ts +48 -0
- package/src/adapter/handle.ts +311 -0
- package/src/adapter/lifecycle.ts +10 -338
- package/src/adapter/schema-sync.ts +155 -0
- package/src/artifacts/cascade.ts +0 -14
- package/src/artifacts/store.ts +60 -10
- package/src/contract/attachment.ts +26 -5
- package/src/contract/config.ts +2 -2
- package/src/contract/migration.ts +4 -1
- package/src/contract/query.ts +6 -6
- package/src/crypto/provider.ts +21 -27
- package/src/diagnostics/dev-log.ts +28 -4
- package/src/diagnostics/errors.ts +17 -16
- package/src/diagnostics/redact.ts +5 -23
- package/src/migration/executor.ts +27 -6
- package/src/migration/ledger.ts +18 -27
- package/src/platform/backup-ios.ts +4 -3
- package/src/platform/bytes.ts +21 -8
- package/src/platform/fs.ts +22 -27
- package/src/platform/location.ts +0 -3
- package/src/query/budgets.ts +32 -5
- package/src/query/cursor.ts +5 -5
- package/src/schema/validate-config.ts +3 -53
- package/src/sqlite/batch.ts +40 -51
- package/src/sqlite/connection.ts +0 -9
- package/src/sqlite/native.ts +20 -19
- package/src/sqlite/row-codec.ts +5 -2
- package/src/sqlite/statements.ts +74 -0
- package/src/testing/conformance/attachments.ts +5 -4
- package/src/testing/conformance/encryption.ts +3 -2
- package/src/testing/conformance/index.ts +1 -1
- package/src/testing/index.ts +2 -0
- package/src/testing/memory-adapter/attachments.ts +146 -0
- package/src/testing/memory-adapter/index.ts +13 -110
- package/src/testing/memory-adapter/store.ts +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,96 @@
|
|
|
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
|
+
|
|
25
|
+
## 0.3.0
|
|
26
|
+
|
|
27
|
+
### Minor Changes
|
|
28
|
+
|
|
29
|
+
- One diagnostic stream: install a sink with `installSdkSink` from
|
|
30
|
+
`@chipmobilesdk/rn-core` and it receives what every package observes (events,
|
|
31
|
+
failures, warnings) as `SdkDiagnostic`s, redacted. Listeners are notified through
|
|
32
|
+
rn-core, so a listener that throws no longer stops the others nor fails the call that
|
|
33
|
+
notified, and its error is reported rather than swallowed. ([#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17))
|
|
34
|
+
|
|
35
|
+
- rn-ads: a banner's auto-refresh is a new cycle, so its `loaded` and `impression`
|
|
36
|
+
events are no longer deduplicated away; the dedupe memory is bounded. A throwing
|
|
37
|
+
status, consent or placement listener is reported as an `internal_error` event.
|
|
38
|
+
- rn-i18n: new `onDiagnostic` config receives every diagnostic (`I18nDiagnostic`,
|
|
39
|
+
with a stable `code`); without it, warnings go to `console.warn` in development
|
|
40
|
+
builds only. The warn-once memory is bounded.
|
|
41
|
+
- rn-chart: now depends on rn-core for its diagnostics.
|
|
42
|
+
- rn-payment: status, entitlement and pending listeners report a throw to the logger
|
|
43
|
+
instead of swallowing it.
|
|
44
|
+
|
|
45
|
+
- The package's errors extend `SdkError` from the new `@chipmobilesdk/rn-core`
|
|
46
|
+
dependency: `isSdkError` recognises them alongside every other package's, and each
|
|
47
|
+
carries `pkg`, `code` and `retryable`. Names and codes are unchanged. ([#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17))
|
|
48
|
+
|
|
49
|
+
- rn-ai and rn-camera: `retryable` is the new name of `recoverable`, which stays as a
|
|
50
|
+
deprecated alias.
|
|
51
|
+
- rn-auth: `retryable` is true for `network-unavailable`. `redact` also removes URLs,
|
|
52
|
+
e-mail addresses and UUIDs.
|
|
53
|
+
- rn-ads: `redact` also removes JWTs and e-mail addresses, and reduces an `AdsError`
|
|
54
|
+
to its code.
|
|
55
|
+
- rn-ai: an error message quoted from the gateway is pattern-redacted.
|
|
56
|
+
- rn-camera: `redact` also removes JWTs, e-mail addresses and UUIDs.
|
|
57
|
+
- rn-theme: `ThemeConfigError` has `code: 'invalid_config'` and `ThemeContrastError`
|
|
58
|
+
`code: 'contrast'` (new `ThemeErrorCode` type).
|
|
59
|
+
- rn-i18n: `I18nConfigError` has `code: 'invalid_config'` and `UnitConversionError`
|
|
60
|
+
`code: 'unit_conversion'` (new `I18nErrorCode` type).
|
|
61
|
+
|
|
62
|
+
- Native peers load through `lazyPeer` from `@chipmobilesdk/rn-core`, on first use,
|
|
63
|
+
and a missing one is each package's own typed error. ([#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17))
|
|
64
|
+
|
|
65
|
+
- rn-auth: creating `auth` no longer loads react-native-app-auth or
|
|
66
|
+
react-native-keychain; each loads on first use. New entrypoints
|
|
67
|
+
`@chipmobilesdk/rn-auth/app-auth` and `/keychain`. The default platform comes from
|
|
68
|
+
rn-core.
|
|
69
|
+
- rn-camera: a preview whose VisionCamera feed cannot load fails the session with
|
|
70
|
+
`cameraUnavailable` (new `controller.reportSurfaceUnavailable()`), instead of
|
|
71
|
+
showing black with no error. A missing VisionCamera module is `cameraUnavailable`.
|
|
72
|
+
- rn-notification: `createMmkvStore` reports a fallback to memory as
|
|
73
|
+
`STATE_STORE_DEGRADED` (to the SDK sinks and a new `onDegraded` option) and
|
|
74
|
+
exposes `durable`.
|
|
75
|
+
- rn-payment: an iOS app validates its catalog against the iOS product ids; the
|
|
76
|
+
default platform used to be Android everywhere. A missing react-native-iap is
|
|
77
|
+
`PAYMENT_UNAVAILABLE`.
|
|
78
|
+
- rn-theme: `buildThemes` runs without React Native (new `hairlineWidth` option) and
|
|
79
|
+
importing the package no longer loads Unistyles; `applyThemeMode` loads it on
|
|
80
|
+
first use.
|
|
81
|
+
- rn-ads, rn-local-db: the same loading, with the same error codes as before.
|
|
82
|
+
|
|
83
|
+
### Patch Changes
|
|
84
|
+
|
|
85
|
+
- The `./testing` memory adapter and conformance suite no longer use Node's `Buffer`, so
|
|
86
|
+
they run on Hermes as well as under Jest; base64 goes through the same byte helpers the
|
|
87
|
+
SQLite adapter uses. Optional properties in the public types (`Query`, `PageRequest`,
|
|
88
|
+
`DatabaseConfig.encryption`, `AttachmentReference.owner`, `StorageError` and others)
|
|
89
|
+
now accept an explicit `undefined`, so apps compiling with
|
|
90
|
+
`exactOptionalPropertyTypes` can pass a value that may be missing. ([#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17))
|
|
91
|
+
- Updated dependencies ([#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17), [#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17), [#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17)):
|
|
92
|
+
- @chipmobilesdk/rn-core@0.1.0
|
|
93
|
+
|
|
3
94
|
## 0.2.1
|
|
4
95
|
|
|
5
96
|
### Patch Changes
|
|
@@ -85,7 +176,7 @@ First release. New package, no existing consumers, no migration burden.
|
|
|
85
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.
|
|
86
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.
|
|
87
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.
|
|
88
|
-
- **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
|
|
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.
|
|
89
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.
|
|
90
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.
|
|
91
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
|
|
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
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
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
|
|
448
|
-
|
|
449
|
-
|
|
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
|
|
|
@@ -512,7 +526,7 @@ To opt in instead, set `backup: { includeInPlatformBackup: true }` and remove th
|
|
|
512
526
|
|
|
513
527
|
## Diagnostics
|
|
514
528
|
|
|
515
|
-
Every failure is a `StorageError` with a stable `code`. **Never parse a message** — use `isCode`:
|
|
529
|
+
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
530
|
|
|
517
531
|
```ts
|
|
518
532
|
import { DiagnosticCodes, isCode, isStorageError } from '@chipmobilesdk/rn-local-db';
|
|
@@ -601,7 +615,11 @@ private and unsupported.
|
|
|
601
615
|
## Testing against the in-memory adapter
|
|
602
616
|
|
|
603
617
|
```ts
|
|
604
|
-
import {
|
|
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
|
|
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
|
+
"version": "0.4.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -37,9 +37,12 @@
|
|
|
37
37
|
],
|
|
38
38
|
"sideEffects": false,
|
|
39
39
|
"scripts": {
|
|
40
|
-
"typecheck": "tsc -p tsconfig.json
|
|
40
|
+
"typecheck": "tsc -p tsconfig.json && tsc -p tsconfig.test.json",
|
|
41
41
|
"pack:check": "npm pack --dry-run"
|
|
42
42
|
},
|
|
43
|
+
"dependencies": {
|
|
44
|
+
"@chipmobilesdk/rn-core": "^0.1.0"
|
|
45
|
+
},
|
|
43
46
|
"peerDependencies": {
|
|
44
47
|
"@dr.pogodin/react-native-fs": ">=2.39.2",
|
|
45
48
|
"@op-engineering/op-sqlite": ">=17.1.0",
|
|
@@ -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.
|
package/src/adapter/budgets.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
//
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
|
|
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
|
-
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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(
|
|
161
|
-
...
|
|
162
|
-
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
return { operation: '
|
|
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>> {
|
package/src/adapter/deletion.ts
CHANGED
|
@@ -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 {
|