@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 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.2.1",
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 --noEmit",
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;
@@ -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 = {
@@ -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
  };
@@ -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 { raise } from '../diagnostics/errors';
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
- let override: CipherModule | null = null;
47
- let cached: CipherModule | null = null;
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 = module;
51
- cached = null;
60
+ cipher.override(module ?? undefined);
52
61
  }
53
62
 
54
63
  export function isCipherAvailable(): boolean {
55
- if (override) {
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
- if (override) {
68
- return override;
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: error,
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
- return typeof __DEV__ !== 'undefined' ? __DEV__ === true : process.env.NODE_ENV !== 'production';
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
- if (!isLogging()) {
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
- export class StorageError extends Error {
26
- readonly code: DiagnosticCode;
27
- readonly retryable: boolean;
28
- readonly detail?: Record<string, string | number | boolean>;
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
- super(init.message ? sanitizeAuthoredMessage(init.message) : code);
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
- if (!text) {
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
- if (!detail) {
111
- return undefined;
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(layout.root, {
36
- NSURLIsExcludedFromBackupKey: isIos() ? !includeInPlatformBackup : undefined,
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. */
@@ -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 (typeof globalThis.atob === 'function') {
43
+ if (host.atob) {
31
44
  let binary: string | undefined;
32
45
  try {
33
- binary = globalThis.atob(base64.replace(/-/g, '+').replace(/_/g, '/'));
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 (typeof globalThis.btoa === 'function') {
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 globalThis.btoa(parts.join(''));
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 (typeof TextEncoder !== 'undefined') {
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 (typeof TextDecoder !== 'undefined') {
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) {
@@ -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 { raise } from '../diagnostics/errors';
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
- let override: FileSystem | null = null;
48
- let cached: FileSystem | null = null;
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 = module;
53
- cached = null;
61
+ fs.override(module ?? undefined);
54
62
  }
55
63
 
56
64
  export function fileSystem(): FileSystem {
57
- if (override) {
58
- return override;
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: error,
67
- });
68
- }
69
- }
70
- return cached!;
69
+ cause,
70
+ }),
71
+ );
71
72
  }
72
73
 
73
74
  export async function pathExists(path: string): Promise<boolean> {
@@ -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 (typeof globalThis.btoa === 'function') {
32
- return globalThis.btoa(unescape(encodeURIComponent(value)));
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 (typeof globalThis.atob === 'function') {
39
- return decodeURIComponent(escape(globalThis.atob(value)));
38
+ if (host.atob) {
39
+ return decodeURIComponent(escape(host.atob(value)));
40
40
  }
41
41
  return utf8String(base64ToBytes(value));
42
42
  }
@@ -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 { raise } from '../diagnostics/errors';
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
- let override: Engine | null = null;
48
- let cached: Engine | null = null;
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 = next;
53
- cached = null;
61
+ sqlite.override(next ?? undefined);
54
62
  }
55
63
 
56
64
  export function engine(): Engine {
57
- if (override) {
58
- return override;
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: error,
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 = Buffer.from('hello attachment', 'utf8').toString('base64');
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(Buffer.from(content, 'base64').length);
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(Buffer.from(slice, 'base64').toString('utf8')).toBe('hello');
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 = Buffer.alloc(101 * 1024 * 1024).toString('base64');
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 = Buffer.from('hello encrypted attachment', 'utf8').toString('base64');
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(Buffer.from(slice, 'base64').toString('utf8')).toBe('encrypted');
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 = Buffer.from(input.content, 'base64');
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 = Buffer.from(content, 'base64');
252
- return bytes.subarray(range.offset, range.offset + range.length).toString('base64');
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 = {