@chipmobilesdk/rn-local-db 0.2.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +69 -0
- package/README.md +1 -1
- package/package.json +5 -2
- package/src/contract/attachment.ts +2 -2
- package/src/contract/config.ts +2 -2
- package/src/contract/query.ts +6 -6
- package/src/crypto/provider.ts +21 -27
- package/src/diagnostics/dev-log.ts +23 -3
- package/src/diagnostics/errors.ts +17 -16
- package/src/diagnostics/redact.ts +5 -23
- package/src/platform/backup-ios.ts +4 -3
- package/src/platform/bytes.ts +21 -8
- package/src/platform/fs.ts +20 -19
- package/src/query/cursor.ts +5 -5
- package/src/sqlite/native.ts +20 -19
- 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/memory-adapter/index.ts +4 -3
- package/src/testing/memory-adapter/store.ts +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,74 @@
|
|
|
1
1
|
# @chipmobilesdk/rn-local-db
|
|
2
2
|
|
|
3
|
+
## 0.3.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- One diagnostic stream: install a sink with `installSdkSink` from
|
|
8
|
+
`@chipmobilesdk/rn-core` and it receives what every package observes (events,
|
|
9
|
+
failures, warnings) as `SdkDiagnostic`s, redacted. Listeners are notified through
|
|
10
|
+
rn-core, so a listener that throws no longer stops the others nor fails the call that
|
|
11
|
+
notified, and its error is reported rather than swallowed. ([#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17))
|
|
12
|
+
|
|
13
|
+
- rn-ads: a banner's auto-refresh is a new cycle, so its `loaded` and `impression`
|
|
14
|
+
events are no longer deduplicated away; the dedupe memory is bounded. A throwing
|
|
15
|
+
status, consent or placement listener is reported as an `internal_error` event.
|
|
16
|
+
- rn-i18n: new `onDiagnostic` config receives every diagnostic (`I18nDiagnostic`,
|
|
17
|
+
with a stable `code`); without it, warnings go to `console.warn` in development
|
|
18
|
+
builds only. The warn-once memory is bounded.
|
|
19
|
+
- rn-chart: now depends on rn-core for its diagnostics.
|
|
20
|
+
- rn-payment: status, entitlement and pending listeners report a throw to the logger
|
|
21
|
+
instead of swallowing it.
|
|
22
|
+
|
|
23
|
+
- The package's errors extend `SdkError` from the new `@chipmobilesdk/rn-core`
|
|
24
|
+
dependency: `isSdkError` recognises them alongside every other package's, and each
|
|
25
|
+
carries `pkg`, `code` and `retryable`. Names and codes are unchanged. ([#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17))
|
|
26
|
+
|
|
27
|
+
- rn-ai and rn-camera: `retryable` is the new name of `recoverable`, which stays as a
|
|
28
|
+
deprecated alias.
|
|
29
|
+
- rn-auth: `retryable` is true for `network-unavailable`. `redact` also removes URLs,
|
|
30
|
+
e-mail addresses and UUIDs.
|
|
31
|
+
- rn-ads: `redact` also removes JWTs and e-mail addresses, and reduces an `AdsError`
|
|
32
|
+
to its code.
|
|
33
|
+
- rn-ai: an error message quoted from the gateway is pattern-redacted.
|
|
34
|
+
- rn-camera: `redact` also removes JWTs, e-mail addresses and UUIDs.
|
|
35
|
+
- rn-theme: `ThemeConfigError` has `code: 'invalid_config'` and `ThemeContrastError`
|
|
36
|
+
`code: 'contrast'` (new `ThemeErrorCode` type).
|
|
37
|
+
- rn-i18n: `I18nConfigError` has `code: 'invalid_config'` and `UnitConversionError`
|
|
38
|
+
`code: 'unit_conversion'` (new `I18nErrorCode` type).
|
|
39
|
+
|
|
40
|
+
- Native peers load through `lazyPeer` from `@chipmobilesdk/rn-core`, on first use,
|
|
41
|
+
and a missing one is each package's own typed error. ([#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17))
|
|
42
|
+
|
|
43
|
+
- rn-auth: creating `auth` no longer loads react-native-app-auth or
|
|
44
|
+
react-native-keychain; each loads on first use. New entrypoints
|
|
45
|
+
`@chipmobilesdk/rn-auth/app-auth` and `/keychain`. The default platform comes from
|
|
46
|
+
rn-core.
|
|
47
|
+
- rn-camera: a preview whose VisionCamera feed cannot load fails the session with
|
|
48
|
+
`cameraUnavailable` (new `controller.reportSurfaceUnavailable()`), instead of
|
|
49
|
+
showing black with no error. A missing VisionCamera module is `cameraUnavailable`.
|
|
50
|
+
- rn-notification: `createMmkvStore` reports a fallback to memory as
|
|
51
|
+
`STATE_STORE_DEGRADED` (to the SDK sinks and a new `onDegraded` option) and
|
|
52
|
+
exposes `durable`.
|
|
53
|
+
- rn-payment: an iOS app validates its catalog against the iOS product ids; the
|
|
54
|
+
default platform used to be Android everywhere. A missing react-native-iap is
|
|
55
|
+
`PAYMENT_UNAVAILABLE`.
|
|
56
|
+
- rn-theme: `buildThemes` runs without React Native (new `hairlineWidth` option) and
|
|
57
|
+
importing the package no longer loads Unistyles; `applyThemeMode` loads it on
|
|
58
|
+
first use.
|
|
59
|
+
- rn-ads, rn-local-db: the same loading, with the same error codes as before.
|
|
60
|
+
|
|
61
|
+
### Patch Changes
|
|
62
|
+
|
|
63
|
+
- The `./testing` memory adapter and conformance suite no longer use Node's `Buffer`, so
|
|
64
|
+
they run on Hermes as well as under Jest; base64 goes through the same byte helpers the
|
|
65
|
+
SQLite adapter uses. Optional properties in the public types (`Query`, `PageRequest`,
|
|
66
|
+
`DatabaseConfig.encryption`, `AttachmentReference.owner`, `StorageError` and others)
|
|
67
|
+
now accept an explicit `undefined`, so apps compiling with
|
|
68
|
+
`exactOptionalPropertyTypes` can pass a value that may be missing. ([#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17))
|
|
69
|
+
- 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)):
|
|
70
|
+
- @chipmobilesdk/rn-core@0.1.0
|
|
71
|
+
|
|
3
72
|
## 0.2.1
|
|
4
73
|
|
|
5
74
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -512,7 +512,7 @@ To opt in instead, set `backup: { includeInPlatformBackup: true }` and remove th
|
|
|
512
512
|
|
|
513
513
|
## Diagnostics
|
|
514
514
|
|
|
515
|
-
Every failure is a `StorageError` with a stable `code`. **Never parse a message** — use `isCode`:
|
|
515
|
+
Every failure is a `StorageError` with a stable `code`. `StorageError` extends `SdkError` from `@chipmobilesdk/rn-core`, so `isSdkError(error)` recognises it alongside every other package's errors, `error.pkg` names this package, and `error.redactedCause` (when present) is the underlying failure reduced to log-safe text. **Never parse a message** — use `isCode`:
|
|
516
516
|
|
|
517
517
|
```ts
|
|
518
518
|
import { DiagnosticCodes, isCode, isStorageError } from '@chipmobilesdk/rn-local-db';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chipmobilesdk/rn-local-db",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.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",
|
|
@@ -14,7 +14,7 @@ export type AttachmentOwner = {
|
|
|
14
14
|
export type AttachmentInput = {
|
|
15
15
|
id: string;
|
|
16
16
|
/** Absent means the attachment belongs to the scope rather than a record. */
|
|
17
|
-
owner?: AttachmentOwner;
|
|
17
|
+
owner?: AttachmentOwner | undefined;
|
|
18
18
|
contentType: string;
|
|
19
19
|
/** Base64 content. Written incrementally; never held whole when encrypted. */
|
|
20
20
|
content: string;
|
|
@@ -22,7 +22,7 @@ export type AttachmentInput = {
|
|
|
22
22
|
|
|
23
23
|
export type AttachmentReference = {
|
|
24
24
|
id: string;
|
|
25
|
-
owner?: AttachmentOwner;
|
|
25
|
+
owner?: AttachmentOwner | undefined;
|
|
26
26
|
contentType: string;
|
|
27
27
|
/** Plaintext byte length, <= 100 MB (FR-088). */
|
|
28
28
|
size: number;
|
package/src/contract/config.ts
CHANGED
|
@@ -35,7 +35,7 @@ export type DatabaseConfig = {
|
|
|
35
35
|
collections: CollectionSchema[];
|
|
36
36
|
migrations?: Migration[];
|
|
37
37
|
/** Absent means unencrypted (FR-097). */
|
|
38
|
-
encryption?: EncryptionConfig;
|
|
38
|
+
encryption?: EncryptionConfig | undefined;
|
|
39
39
|
/** Absent means excluded from platform backup (FR-109). */
|
|
40
40
|
backup?: BackupConfig;
|
|
41
41
|
/** Governs whether deleting a record removes its artifacts (FR-070). */
|
|
@@ -46,7 +46,7 @@ export type DatabaseConfig = {
|
|
|
46
46
|
export type ScopeTargetConfig = {
|
|
47
47
|
name: string;
|
|
48
48
|
scope: ScopeDescriptor;
|
|
49
|
-
encryption?: EncryptionConfig;
|
|
49
|
+
encryption?: EncryptionConfig | undefined;
|
|
50
50
|
};
|
|
51
51
|
|
|
52
52
|
export type DatabaseTargetConfig = {
|
package/src/contract/query.ts
CHANGED
|
@@ -51,13 +51,13 @@ export type PageRequest = {
|
|
|
51
51
|
/** 1..500 (FR-036, FR-088). */
|
|
52
52
|
size: number;
|
|
53
53
|
/** Opaque keyset cursor from a previous page. */
|
|
54
|
-
cursor?: string;
|
|
54
|
+
cursor?: string | undefined;
|
|
55
55
|
};
|
|
56
56
|
|
|
57
57
|
export type Query = {
|
|
58
|
-
filter?: FilterNode;
|
|
59
|
-
sort?: SortKey[];
|
|
60
|
-
page?: PageRequest;
|
|
58
|
+
filter?: FilterNode | undefined;
|
|
59
|
+
sort?: SortKey[] | undefined;
|
|
60
|
+
page?: PageRequest | undefined;
|
|
61
61
|
/** Soft-deleted records are excluded unless this is true (FR-021). */
|
|
62
62
|
includeSoftDeleted?: boolean;
|
|
63
63
|
};
|
|
@@ -65,7 +65,7 @@ export type Query = {
|
|
|
65
65
|
export type ResultPage<T extends StorableRecordData = StorableRecordData> = {
|
|
66
66
|
records: StoredRecord<T>[];
|
|
67
67
|
/** Absent when there is no further page. */
|
|
68
|
-
cursor?: string;
|
|
68
|
+
cursor?: string | undefined;
|
|
69
69
|
hasMore: boolean;
|
|
70
70
|
};
|
|
71
71
|
|
|
@@ -76,6 +76,6 @@ export type ResultPage<T extends StorableRecordData = StorableRecordData> = {
|
|
|
76
76
|
export type IndexCoverageReport = {
|
|
77
77
|
collection: string;
|
|
78
78
|
usesIndex: boolean;
|
|
79
|
-
indexName?: string;
|
|
79
|
+
indexName?: string | undefined;
|
|
80
80
|
detail: string;
|
|
81
81
|
};
|
package/src/crypto/provider.ts
CHANGED
|
@@ -9,8 +9,10 @@
|
|
|
9
9
|
* was asked for and cannot be delivered (research.md R1a).
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
+
import { lazyPeer } from '@chipmobilesdk/rn-core';
|
|
13
|
+
|
|
12
14
|
import { DiagnosticCodes } from '../contract/errors';
|
|
13
|
-
import {
|
|
15
|
+
import { StorageError } from '../diagnostics/errors';
|
|
14
16
|
|
|
15
17
|
export type CipherModule = {
|
|
16
18
|
createCipheriv(
|
|
@@ -43,39 +45,31 @@ export type CipherModule = {
|
|
|
43
45
|
};
|
|
44
46
|
};
|
|
45
47
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
+
/** Loaded on first use (rn-core's lazy peer); optional, so absence is a normal answer. */
|
|
49
|
+
const cipher = lazyPeer('react-native-quick-crypto', () => {
|
|
50
|
+
// Inside `try`, so Metro bundles an app that leaves this optional peer out.
|
|
51
|
+
try {
|
|
52
|
+
return require('react-native-quick-crypto') as CipherModule;
|
|
53
|
+
} catch (cause) {
|
|
54
|
+
throw cause;
|
|
55
|
+
}
|
|
56
|
+
});
|
|
48
57
|
|
|
58
|
+
/** Swap the module. Tests only; `null` goes back to the real one. */
|
|
49
59
|
export function setCipherModule(module: CipherModule | null): void {
|
|
50
|
-
override
|
|
51
|
-
cached = null;
|
|
60
|
+
cipher.override(module ?? undefined);
|
|
52
61
|
}
|
|
53
62
|
|
|
54
63
|
export function isCipherAvailable(): boolean {
|
|
55
|
-
|
|
56
|
-
return true;
|
|
57
|
-
}
|
|
58
|
-
try {
|
|
59
|
-
require('react-native-quick-crypto');
|
|
60
|
-
return true;
|
|
61
|
-
} catch {
|
|
62
|
-
return false;
|
|
63
|
-
}
|
|
64
|
+
return cipher.isAvailable();
|
|
64
65
|
}
|
|
65
66
|
|
|
66
67
|
export function cipherModule(): CipherModule {
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
if (!cached) {
|
|
71
|
-
try {
|
|
72
|
-
cached = require('react-native-quick-crypto') as CipherModule;
|
|
73
|
-
} catch (error) {
|
|
74
|
-
raise(DiagnosticCodes.ENCRYPTION_UNAVAILABLE, {
|
|
68
|
+
return cipher.require(
|
|
69
|
+
(_peer, cause) =>
|
|
70
|
+
new StorageError(DiagnosticCodes.ENCRYPTION_UNAVAILABLE, {
|
|
75
71
|
message: 'cipher peer is not installed',
|
|
76
|
-
cause
|
|
77
|
-
})
|
|
78
|
-
|
|
79
|
-
}
|
|
80
|
-
return cached!;
|
|
72
|
+
cause,
|
|
73
|
+
}),
|
|
74
|
+
);
|
|
81
75
|
}
|
|
@@ -7,10 +7,12 @@
|
|
|
7
7
|
* payload, so a future caller cannot pass one by accident.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
import { createDiagnosticChannel, hasSdkSinks } from '@chipmobilesdk/rn-core';
|
|
11
|
+
|
|
10
12
|
export type DiagnosticEvent = {
|
|
11
13
|
operation: string;
|
|
12
14
|
durationMs?: number;
|
|
13
|
-
count?: number;
|
|
15
|
+
count?: number | undefined;
|
|
14
16
|
collection?: string;
|
|
15
17
|
adapter?: 'sqlite' | 'memory';
|
|
16
18
|
/** A diagnostic code when the operation failed. Never a message. */
|
|
@@ -29,10 +31,27 @@ export function setDiagnosticLogger(next: DiagnosticLogger | null): void {
|
|
|
29
31
|
function isDevelopmentBuild(): boolean {
|
|
30
32
|
// __DEV__ is a React Native global; the typeof guard keeps this usable
|
|
31
33
|
// under plain Node when the package is exercised from Jest.
|
|
32
|
-
|
|
34
|
+
if (typeof __DEV__ !== 'undefined') return __DEV__ === true;
|
|
35
|
+
const { process } = globalThis as { process?: { env?: Record<string, string | undefined> } };
|
|
36
|
+
return process?.env?.NODE_ENV !== 'production';
|
|
33
37
|
}
|
|
34
38
|
|
|
39
|
+
/**
|
|
40
|
+
* A failed operation also reaches the app's SDK sinks (`installSdkSink` from
|
|
41
|
+
* rn-core, CORE-08), in any build: its code, the operation and the collection,
|
|
42
|
+
* which are identifiers this package defines. Successes stay with the
|
|
43
|
+
* development logger.
|
|
44
|
+
*/
|
|
45
|
+
const channel = createDiagnosticChannel('rn-local-db');
|
|
46
|
+
|
|
35
47
|
export function logDiagnostic(event: DiagnosticEvent): void {
|
|
48
|
+
if (event.outcome !== undefined) {
|
|
49
|
+
channel.emit(event.outcome, 'warn', {
|
|
50
|
+
operation: event.operation,
|
|
51
|
+
...(event.collection === undefined ? {} : { collection: event.collection }),
|
|
52
|
+
...(event.adapter === undefined ? {} : { adapter: event.adapter }),
|
|
53
|
+
});
|
|
54
|
+
}
|
|
36
55
|
if (!logger || !isDevelopmentBuild()) {
|
|
37
56
|
return;
|
|
38
57
|
}
|
|
@@ -59,7 +78,8 @@ export function instrument<T extends Record<string, (...args: never[]) => unknow
|
|
|
59
78
|
adapter: 'sqlite' | 'memory',
|
|
60
79
|
operations: T,
|
|
61
80
|
): T {
|
|
62
|
-
|
|
81
|
+
// Unwrapped unless someone is listening: production pays nothing by default.
|
|
82
|
+
if (!isLogging() && !hasSdkSinks()) {
|
|
63
83
|
return operations;
|
|
64
84
|
}
|
|
65
85
|
|
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
* error object at all.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import { SdkError } from '@chipmobilesdk/rn-core';
|
|
10
|
+
|
|
9
11
|
import { DiagnosticCodes, RETRYABLE_CODES, type DiagnosticCode } from '../contract/errors';
|
|
10
12
|
import type { MigrationFailureDetail } from '../contract/migration';
|
|
11
13
|
import { redactCause, redactDetail, sanitizeAuthoredMessage } from './redact';
|
|
@@ -17,31 +19,30 @@ export type StorageErrorInit = {
|
|
|
17
19
|
* identifier. `authored-messages.test.ts` checks every call site.
|
|
18
20
|
*/
|
|
19
21
|
message?: string;
|
|
20
|
-
detail?: Record<string, unknown
|
|
21
|
-
migration?: MigrationFailureDetail;
|
|
22
|
+
detail?: Record<string, unknown> | undefined;
|
|
23
|
+
migration?: MigrationFailureDetail | undefined;
|
|
22
24
|
cause?: unknown;
|
|
23
25
|
};
|
|
24
26
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
readonly
|
|
28
|
-
readonly
|
|
29
|
-
readonly migration?: MigrationFailureDetail;
|
|
30
|
-
readonly redactedCause?: string;
|
|
27
|
+
/** The package's error; an `SdkError` (`@chipmobilesdk/rn-core`) with `pkg: 'rn-local-db'`. */
|
|
28
|
+
export class StorageError extends SdkError<DiagnosticCode> {
|
|
29
|
+
readonly detail?: Record<string, string | number | boolean> | undefined;
|
|
30
|
+
readonly migration?: MigrationFailureDetail | undefined;
|
|
31
31
|
|
|
32
32
|
constructor(code: DiagnosticCode, init: StorageErrorInit = {}) {
|
|
33
|
-
|
|
33
|
+
// Call sites pass only authored literals (authored-messages.test.ts).
|
|
34
|
+
const message = init.message ? sanitizeAuthoredMessage(init.message) : code;
|
|
35
|
+
// `cause` is deliberately NOT kept: the native error it came from may
|
|
36
|
+
// quote the offending value. Only its redacted identifier survives.
|
|
37
|
+
super('rn-local-db', code, {
|
|
38
|
+
message,
|
|
39
|
+
retryable: RETRYABLE_CODES.has(code),
|
|
40
|
+
redactedCause: redactCause(init.cause),
|
|
41
|
+
});
|
|
34
42
|
this.name = 'StorageError';
|
|
35
|
-
this.code = code;
|
|
36
|
-
this.retryable = RETRYABLE_CODES.has(code);
|
|
37
43
|
this.detail = redactDetail(init.detail);
|
|
38
44
|
// Version numbers and an outcome enum — no payload can hide in these.
|
|
39
45
|
this.migration = init.migration;
|
|
40
|
-
this.redactedCause = redactCause(init.cause);
|
|
41
|
-
// `cause` is deliberately NOT assigned: the native error it came from
|
|
42
|
-
// may quote the offending value, and assigning it would reintroduce
|
|
43
|
-
// exactly the leak redaction exists to prevent.
|
|
44
|
-
Object.setPrototypeOf(this, StorageError.prototype);
|
|
45
46
|
}
|
|
46
47
|
}
|
|
47
48
|
|
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
* (FR-079, FR-099).
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
+
import { REDACTED, leadingIdentifier, pickAllowed } from '@chipmobilesdk/rn-core';
|
|
12
|
+
|
|
11
13
|
/**
|
|
12
14
|
* The allowlist IS the mechanism. Every key here has been reviewed as
|
|
13
15
|
* incapable of carrying user data: identifiers the app already knows, sizes,
|
|
@@ -37,8 +39,6 @@ export const SAFE_DETAIL_KEYS = new Set([
|
|
|
37
39
|
'limit',
|
|
38
40
|
]);
|
|
39
41
|
|
|
40
|
-
const REDACTED = '[redacted]';
|
|
41
|
-
|
|
42
42
|
/**
|
|
43
43
|
* Reduce foreign text to an identifier, or to nothing.
|
|
44
44
|
*
|
|
@@ -60,15 +60,8 @@ const REDACTED = '[redacted]';
|
|
|
60
60
|
* Everything else becomes `[redacted]`. A developer who needs more can
|
|
61
61
|
* reproduce with the development logger; a crash report cannot.
|
|
62
62
|
*/
|
|
63
|
-
const IDENTIFIER_TOKEN = /^([A-Z][A-Z0-9_]{2,})\b/;
|
|
64
|
-
|
|
65
63
|
export function redactForeignText(text: string): string {
|
|
66
|
-
|
|
67
|
-
return '';
|
|
68
|
-
}
|
|
69
|
-
const firstLine = text.split('\n')[0]!.trim();
|
|
70
|
-
const token = IDENTIFIER_TOKEN.exec(firstLine);
|
|
71
|
-
return token ? token[1]! : REDACTED;
|
|
64
|
+
return leadingIdentifier(text);
|
|
72
65
|
}
|
|
73
66
|
|
|
74
67
|
const MAX_AUTHORED_MESSAGE = 160;
|
|
@@ -107,19 +100,8 @@ export function sanitizeIdentifier(identifier: string): string {
|
|
|
107
100
|
export function redactDetail(
|
|
108
101
|
detail: Record<string, unknown> | undefined,
|
|
109
102
|
): Record<string, string | number | boolean> | undefined {
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
}
|
|
113
|
-
const safe: Record<string, string | number | boolean> = {};
|
|
114
|
-
for (const [key, value] of Object.entries(detail)) {
|
|
115
|
-
if (!SAFE_DETAIL_KEYS.has(key)) {
|
|
116
|
-
continue;
|
|
117
|
-
}
|
|
118
|
-
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
|
|
119
|
-
safe[key] = value;
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
return Object.keys(safe).length > 0 ? safe : undefined;
|
|
103
|
+
const safe = pickAllowed(detail, SAFE_DETAIL_KEYS);
|
|
104
|
+
return safe && Object.keys(safe).length > 0 ? safe : undefined;
|
|
123
105
|
}
|
|
124
106
|
|
|
125
107
|
/** Reduce an unknown thrown value to a message-shaped string, redacted. */
|
|
@@ -32,9 +32,10 @@ export async function ensureManagedRoot(
|
|
|
32
32
|
layout: StorageLayout,
|
|
33
33
|
includeInPlatformBackup: boolean,
|
|
34
34
|
): Promise<void> {
|
|
35
|
-
await fileSystem().mkdir(
|
|
36
|
-
|
|
37
|
-
|
|
35
|
+
await fileSystem().mkdir(
|
|
36
|
+
layout.root,
|
|
37
|
+
isIos() ? { NSURLIsExcludedFromBackupKey: !includeInPlatformBackup } : {},
|
|
38
|
+
);
|
|
38
39
|
}
|
|
39
40
|
|
|
40
41
|
/** What the package can state about iOS, where it enforces rather than reports. */
|
package/src/platform/bytes.ts
CHANGED
|
@@ -21,16 +21,29 @@ const LOOKUP = (() => {
|
|
|
21
21
|
return table;
|
|
22
22
|
})();
|
|
23
23
|
|
|
24
|
+
/**
|
|
25
|
+
* Codecs Hermes and Node provide but React Native's type declarations omit.
|
|
26
|
+
* Read through this view so the package type-checks without DOM or Node types.
|
|
27
|
+
*/
|
|
28
|
+
interface HostCodecs {
|
|
29
|
+
atob?: (data: string) => string;
|
|
30
|
+
btoa?: (data: string) => string;
|
|
31
|
+
TextEncoder?: new () => { encode(input: string): Uint8Array };
|
|
32
|
+
TextDecoder?: new () => { decode(input: Uint8Array): string };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const host = globalThis as unknown as HostCodecs;
|
|
36
|
+
|
|
24
37
|
/* eslint-disable no-bitwise -- base64 is bit packing; there is no clearer way to write it. */
|
|
25
38
|
|
|
26
39
|
/** Characters per `String.fromCharCode` call; large spreads overflow the stack. */
|
|
27
40
|
const CHUNK = 0x8000;
|
|
28
41
|
|
|
29
42
|
export function base64ToBytes(base64: string): Uint8Array {
|
|
30
|
-
if (
|
|
43
|
+
if (host.atob) {
|
|
31
44
|
let binary: string | undefined;
|
|
32
45
|
try {
|
|
33
|
-
binary =
|
|
46
|
+
binary = host.atob(base64.replace(/-/g, '+').replace(/_/g, '/'));
|
|
34
47
|
} catch {
|
|
35
48
|
// atob is strict where Buffer was lenient; fall through to the
|
|
36
49
|
// tolerant decoder rather than turn stray characters into a crash.
|
|
@@ -63,12 +76,12 @@ export function base64ToBytes(base64: string): Uint8Array {
|
|
|
63
76
|
}
|
|
64
77
|
|
|
65
78
|
export function bytesToBase64(bytes: Uint8Array): string {
|
|
66
|
-
if (
|
|
79
|
+
if (host.btoa) {
|
|
67
80
|
const parts: string[] = [];
|
|
68
81
|
for (let index = 0; index < bytes.length; index += CHUNK) {
|
|
69
82
|
parts.push(String.fromCharCode(...bytes.subarray(index, index + CHUNK)));
|
|
70
83
|
}
|
|
71
|
-
return
|
|
84
|
+
return host.btoa(parts.join(''));
|
|
72
85
|
}
|
|
73
86
|
const parts: string[] = [];
|
|
74
87
|
for (let start = 0; start < bytes.length; start += CHUNK * 3) {
|
|
@@ -98,8 +111,8 @@ export function bytesToBase64(bytes: Uint8Array): string {
|
|
|
98
111
|
|
|
99
112
|
/** UTF-8 encoding. `TextEncoder` is in Hermes and Node; the loop is the fallback. */
|
|
100
113
|
export function utf8Bytes(value: string): Uint8Array {
|
|
101
|
-
if (
|
|
102
|
-
return new TextEncoder().encode(value);
|
|
114
|
+
if (host.TextEncoder) {
|
|
115
|
+
return new host.TextEncoder().encode(value);
|
|
103
116
|
}
|
|
104
117
|
const encoded = unescape(encodeURIComponent(value));
|
|
105
118
|
const bytes = new Uint8Array(encoded.length);
|
|
@@ -111,8 +124,8 @@ export function utf8Bytes(value: string): Uint8Array {
|
|
|
111
124
|
|
|
112
125
|
/** UTF-8 decoding, the inverse of `utf8Bytes`. */
|
|
113
126
|
export function utf8String(bytes: Uint8Array): string {
|
|
114
|
-
if (
|
|
115
|
-
return new TextDecoder().decode(bytes);
|
|
127
|
+
if (host.TextDecoder) {
|
|
128
|
+
return new host.TextDecoder().decode(bytes);
|
|
116
129
|
}
|
|
117
130
|
let binary = '';
|
|
118
131
|
for (const byte of bytes) {
|
package/src/platform/fs.ts
CHANGED
|
@@ -8,8 +8,10 @@
|
|
|
8
8
|
* rule the ESLint config enforces.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
+
import { lazyPeer } from '@chipmobilesdk/rn-core';
|
|
12
|
+
|
|
11
13
|
import { DiagnosticCodes } from '../contract/errors';
|
|
12
|
-
import {
|
|
14
|
+
import { StorageError } from '../diagnostics/errors';
|
|
13
15
|
|
|
14
16
|
export type FileStat = {
|
|
15
17
|
path: string;
|
|
@@ -44,30 +46,29 @@ export type FileSystem = {
|
|
|
44
46
|
exists(path: string): Promise<boolean>;
|
|
45
47
|
};
|
|
46
48
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
+
/** Loaded on first use (rn-core's lazy peer); a missing peer is `STORAGE_ACCESS`. */
|
|
50
|
+
const fs = lazyPeer('@dr.pogodin/react-native-fs', () => {
|
|
51
|
+
// Inside `try`, so Metro bundles an app that leaves this optional peer out.
|
|
52
|
+
try {
|
|
53
|
+
return require('@dr.pogodin/react-native-fs') as FileSystem;
|
|
54
|
+
} catch (cause) {
|
|
55
|
+
throw cause;
|
|
56
|
+
}
|
|
57
|
+
});
|
|
49
58
|
|
|
50
|
-
/** Swap the peer. Tests and the demo app's in-memory mode only. */
|
|
59
|
+
/** Swap the peer. Tests and the demo app's in-memory mode only; `null` restores it. */
|
|
51
60
|
export function setFileSystem(module: FileSystem | null): void {
|
|
52
|
-
override
|
|
53
|
-
cached = null;
|
|
61
|
+
fs.override(module ?? undefined);
|
|
54
62
|
}
|
|
55
63
|
|
|
56
64
|
export function fileSystem(): FileSystem {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
if (!cached) {
|
|
61
|
-
try {
|
|
62
|
-
cached = require('@dr.pogodin/react-native-fs') as FileSystem;
|
|
63
|
-
} catch (error) {
|
|
64
|
-
raise(DiagnosticCodes.STORAGE_ACCESS, {
|
|
65
|
+
return fs.require(
|
|
66
|
+
(_peer, cause) =>
|
|
67
|
+
new StorageError(DiagnosticCodes.STORAGE_ACCESS, {
|
|
65
68
|
message: 'filesystem peer is not installed',
|
|
66
|
-
cause
|
|
67
|
-
})
|
|
68
|
-
|
|
69
|
-
}
|
|
70
|
-
return cached!;
|
|
69
|
+
cause,
|
|
70
|
+
}),
|
|
71
|
+
);
|
|
71
72
|
}
|
|
72
73
|
|
|
73
74
|
export async function pathExists(path: string): Promise<boolean> {
|
package/src/query/cursor.ts
CHANGED
|
@@ -14,7 +14,7 @@ import type { StoredRecord } from '../contract/record';
|
|
|
14
14
|
import { raise } from '../diagnostics/errors';
|
|
15
15
|
import { compareValues } from './compare';
|
|
16
16
|
import { sortValueOf } from './sort';
|
|
17
|
-
import { base64ToBytes, bytesToBase64, utf8Bytes, utf8String } from '../platform/bytes';
|
|
17
|
+
import { base64ToBytes, bytesToBase64, host, utf8Bytes, utf8String } from '../platform/bytes';
|
|
18
18
|
|
|
19
19
|
export type CursorPayload = {
|
|
20
20
|
/** Sort key fields and directions, so a cursor cannot be reused across shapes. */
|
|
@@ -28,15 +28,15 @@ function shapeOf(sort: SortKey[]): string {
|
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
function toBase64(value: string): string {
|
|
31
|
-
if (
|
|
32
|
-
return
|
|
31
|
+
if (host.btoa) {
|
|
32
|
+
return host.btoa(unescape(encodeURIComponent(value)));
|
|
33
33
|
}
|
|
34
34
|
return bytesToBase64(utf8Bytes(value));
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
function fromBase64(value: string): string {
|
|
38
|
-
if (
|
|
39
|
-
return decodeURIComponent(escape(
|
|
38
|
+
if (host.atob) {
|
|
39
|
+
return decodeURIComponent(escape(host.atob(value)));
|
|
40
40
|
}
|
|
41
41
|
return utf8String(base64ToBytes(value));
|
|
42
42
|
}
|
package/src/sqlite/native.ts
CHANGED
|
@@ -8,8 +8,10 @@
|
|
|
8
8
|
* express the synchronous call is a stronger guarantee than a review comment.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
+
import { lazyPeer } from '@chipmobilesdk/rn-core';
|
|
12
|
+
|
|
11
13
|
import { DiagnosticCodes } from '../contract/errors';
|
|
12
|
-
import {
|
|
14
|
+
import { StorageError } from '../diagnostics/errors';
|
|
13
15
|
|
|
14
16
|
export type SqlValue = string | number | boolean | null | undefined;
|
|
15
17
|
|
|
@@ -44,30 +46,29 @@ export type Engine = {
|
|
|
44
46
|
isSQLCipher?: () => boolean;
|
|
45
47
|
};
|
|
46
48
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
+
/** Loaded on first use (rn-core's lazy peer); a missing peer is `OPEN_FAILED`. */
|
|
50
|
+
const sqlite = lazyPeer('@op-engineering/op-sqlite', () => {
|
|
51
|
+
// Inside `try`, so Metro bundles an app that leaves this optional peer out.
|
|
52
|
+
try {
|
|
53
|
+
return require('@op-engineering/op-sqlite') as Engine;
|
|
54
|
+
} catch (cause) {
|
|
55
|
+
throw cause;
|
|
56
|
+
}
|
|
57
|
+
});
|
|
49
58
|
|
|
50
|
-
/** Swap the engine. Tests only. */
|
|
59
|
+
/** Swap the engine. Tests only; `null` goes back to the real one. */
|
|
51
60
|
export function setEngine(next: Engine | null): void {
|
|
52
|
-
override
|
|
53
|
-
cached = null;
|
|
61
|
+
sqlite.override(next ?? undefined);
|
|
54
62
|
}
|
|
55
63
|
|
|
56
64
|
export function engine(): Engine {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
if (!cached) {
|
|
61
|
-
try {
|
|
62
|
-
cached = require('@op-engineering/op-sqlite') as Engine;
|
|
63
|
-
} catch (error) {
|
|
64
|
-
raise(DiagnosticCodes.OPEN_FAILED, {
|
|
65
|
+
return sqlite.require(
|
|
66
|
+
(_peer, cause) =>
|
|
67
|
+
new StorageError(DiagnosticCodes.OPEN_FAILED, {
|
|
65
68
|
message: 'storage engine peer is not installed',
|
|
66
|
-
cause
|
|
67
|
-
})
|
|
68
|
-
|
|
69
|
-
}
|
|
70
|
-
return cached!;
|
|
69
|
+
cause,
|
|
70
|
+
}),
|
|
71
|
+
);
|
|
71
72
|
}
|
|
72
73
|
|
|
73
74
|
/**
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { DiagnosticCodes } from '../../contract/errors';
|
|
2
2
|
import { isCode } from '../../diagnostics/errors';
|
|
3
|
+
import { base64ToBytes, bytesToBase64, utf8Bytes, utf8String } from '../../platform/bytes';
|
|
3
4
|
import type { ConformanceContext } from './index';
|
|
4
5
|
|
|
5
6
|
/** US7: artifacts the package owns, and the reconciliation that keeps them honest. */
|
|
@@ -8,7 +9,7 @@ export function registerAttachmentConformance(context: ConformanceContext): void
|
|
|
8
9
|
beforeEach(() => context.reset());
|
|
9
10
|
|
|
10
11
|
const scope = { kind: 'guest' } as const;
|
|
11
|
-
const content =
|
|
12
|
+
const content = bytesToBase64(utf8Bytes('hello attachment'));
|
|
12
13
|
|
|
13
14
|
it('stores a reference alongside the artifact', async () => {
|
|
14
15
|
const handle = await context.adapter.open(context.configFor(scope));
|
|
@@ -20,7 +21,7 @@ export function registerAttachmentConformance(context: ConformanceContext): void
|
|
|
20
21
|
content,
|
|
21
22
|
});
|
|
22
23
|
|
|
23
|
-
expect(reference.size).toBe(
|
|
24
|
+
expect(reference.size).toBe(base64ToBytes(content).length);
|
|
24
25
|
expect(await attachments.read('a1')).toBe(content);
|
|
25
26
|
expect((await attachments.availability('a1')).state).toBe('available');
|
|
26
27
|
await handle.close();
|
|
@@ -32,7 +33,7 @@ export function registerAttachmentConformance(context: ConformanceContext): void
|
|
|
32
33
|
await attachments.write({ id: 'a1', contentType: 'text/plain', content });
|
|
33
34
|
|
|
34
35
|
const slice = await attachments.read('a1', { offset: 0, length: 5 });
|
|
35
|
-
expect(
|
|
36
|
+
expect(utf8String(base64ToBytes(slice))).toBe('hello');
|
|
36
37
|
await handle.close();
|
|
37
38
|
});
|
|
38
39
|
|
|
@@ -112,7 +113,7 @@ export function registerAttachmentConformance(context: ConformanceContext): void
|
|
|
112
113
|
|
|
113
114
|
it('rejects an artifact over the size budget', async () => {
|
|
114
115
|
const handle = await context.adapter.open(context.configFor(scope));
|
|
115
|
-
const huge =
|
|
116
|
+
const huge = bytesToBase64(new Uint8Array(101 * 1024 * 1024));
|
|
116
117
|
|
|
117
118
|
let thrown: unknown;
|
|
118
119
|
try {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { DiagnosticCodes } from '../../contract/errors';
|
|
2
2
|
import type { KeyProvider } from '../../contract/encryption';
|
|
3
3
|
import { isCode } from '../../diagnostics/errors';
|
|
4
|
+
import { base64ToBytes, bytesToBase64, utf8Bytes, utf8String } from '../../platform/bytes';
|
|
4
5
|
import type { ConformanceContext } from './index';
|
|
5
6
|
|
|
6
7
|
/** US10: opt-in encryption, and failures that stay apart from each other. */
|
|
@@ -47,12 +48,12 @@ export function registerEncryptionConformance(context: ConformanceContext): void
|
|
|
47
48
|
const handle = await context.adapter.open(
|
|
48
49
|
context.configFor(scope, { encryption: encrypted }),
|
|
49
50
|
);
|
|
50
|
-
const content =
|
|
51
|
+
const content = bytesToBase64(utf8Bytes('hello encrypted attachment'));
|
|
51
52
|
await handle.attachments().write({ id: 'a1', contentType: 'text/plain', content });
|
|
52
53
|
|
|
53
54
|
expect(await handle.attachments().read('a1')).toBe(content);
|
|
54
55
|
const slice = await handle.attachments().read('a1', { offset: 6, length: 9 });
|
|
55
|
-
expect(
|
|
56
|
+
expect(utf8String(base64ToBytes(slice))).toBe('encrypted');
|
|
56
57
|
expect((await handle.attachments().availability('a1')).state).toBe('available');
|
|
57
58
|
await handle.close();
|
|
58
59
|
});
|
|
@@ -59,7 +59,7 @@ export type ConformanceOptions = {
|
|
|
59
59
|
* — an encrypted database that quietly behaved differently would be a
|
|
60
60
|
* second implementation nobody tested.
|
|
61
61
|
*/
|
|
62
|
-
encryption?: EncryptionConfig;
|
|
62
|
+
encryption?: EncryptionConfig | undefined;
|
|
63
63
|
};
|
|
64
64
|
|
|
65
65
|
export type ConformanceSection =
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* (research.md R8). Documented divergences are listed in the package README.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import { base64ToBytes, bytesToBase64 } from '../../platform/bytes';
|
|
9
10
|
import { createTransactionQueue } from '../../sqlite/transaction-queue';
|
|
10
11
|
import type {
|
|
11
12
|
AttachmentInput,
|
|
@@ -219,7 +220,7 @@ export function createMemoryAdapter(options: MemoryAdapterOptions = {}): Storage
|
|
|
219
220
|
assertOpen();
|
|
220
221
|
throwIfFaulted('attachmentWrite');
|
|
221
222
|
assertValidAttachmentId(input.id);
|
|
222
|
-
const bytes =
|
|
223
|
+
const bytes = base64ToBytes(input.content);
|
|
223
224
|
assertArtifactWithinBudget(input.id, bytes.length);
|
|
224
225
|
const reference: AttachmentReference = {
|
|
225
226
|
id: input.id,
|
|
@@ -248,8 +249,8 @@ export function createMemoryAdapter(options: MemoryAdapterOptions = {}): Storage
|
|
|
248
249
|
if (!range) {
|
|
249
250
|
return content;
|
|
250
251
|
}
|
|
251
|
-
const bytes =
|
|
252
|
-
return bytes.subarray(range.offset, range.offset + range.length)
|
|
252
|
+
const bytes = base64ToBytes(content);
|
|
253
|
+
return bytesToBase64(bytes.subarray(range.offset, range.offset + range.length));
|
|
253
254
|
},
|
|
254
255
|
async availability(id) {
|
|
255
256
|
assertOpen();
|
|
@@ -16,9 +16,9 @@ export type MemoryRow = {
|
|
|
16
16
|
/** Serialized on write, parsed on read — the same payload-as-truth rule
|
|
17
17
|
* the SQLite adapter follows, so a mutation after write cannot leak in. */
|
|
18
18
|
payload: string;
|
|
19
|
-
createdAt?: number;
|
|
20
|
-
updatedAt?: number;
|
|
21
|
-
deletedAt?: number | null;
|
|
19
|
+
createdAt?: number | undefined;
|
|
20
|
+
updatedAt?: number | undefined;
|
|
21
|
+
deletedAt?: number | null | undefined;
|
|
22
22
|
};
|
|
23
23
|
|
|
24
24
|
export type MemoryScopeStore = {
|