@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.
Files changed (41) hide show
  1. package/CHANGELOG.md +92 -1
  2. package/README.md +31 -12
  3. package/package.json +5 -2
  4. package/skills/sdk-rn-local-db/SKILL.md +1 -1
  5. package/src/adapter/budgets.ts +1 -10
  6. package/src/adapter/collection.ts +38 -66
  7. package/src/adapter/deletion.ts +48 -0
  8. package/src/adapter/handle.ts +311 -0
  9. package/src/adapter/lifecycle.ts +10 -338
  10. package/src/adapter/schema-sync.ts +155 -0
  11. package/src/artifacts/cascade.ts +0 -14
  12. package/src/artifacts/store.ts +60 -10
  13. package/src/contract/attachment.ts +26 -5
  14. package/src/contract/config.ts +2 -2
  15. package/src/contract/migration.ts +4 -1
  16. package/src/contract/query.ts +6 -6
  17. package/src/crypto/provider.ts +21 -27
  18. package/src/diagnostics/dev-log.ts +28 -4
  19. package/src/diagnostics/errors.ts +17 -16
  20. package/src/diagnostics/redact.ts +5 -23
  21. package/src/migration/executor.ts +27 -6
  22. package/src/migration/ledger.ts +18 -27
  23. package/src/platform/backup-ios.ts +4 -3
  24. package/src/platform/bytes.ts +21 -8
  25. package/src/platform/fs.ts +22 -27
  26. package/src/platform/location.ts +0 -3
  27. package/src/query/budgets.ts +32 -5
  28. package/src/query/cursor.ts +5 -5
  29. package/src/schema/validate-config.ts +3 -53
  30. package/src/sqlite/batch.ts +40 -51
  31. package/src/sqlite/connection.ts +0 -9
  32. package/src/sqlite/native.ts +20 -19
  33. package/src/sqlite/row-codec.ts +5 -2
  34. package/src/sqlite/statements.ts +74 -0
  35. package/src/testing/conformance/attachments.ts +5 -4
  36. package/src/testing/conformance/encryption.ts +3 -2
  37. package/src/testing/conformance/index.ts +1 -1
  38. package/src/testing/index.ts +2 -0
  39. package/src/testing/memory-adapter/attachments.ts +146 -0
  40. package/src/testing/memory-adapter/index.ts +13 -110
  41. package/src/testing/memory-adapter/store.ts +3 -3
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Bringing the physical tables in line with the declared schema, and skipping
3
+ * that work when nothing changed (LDB-07, LDB-11).
4
+ *
5
+ * Until 0.4.0 every open ran the whole set — CREATE TABLE, PRAGMA table_info,
6
+ * CREATE INDEX per index, for every collection — some sixty native round-trips
7
+ * for ten collections, on the startup path. The applied schema is now recorded
8
+ * in `_meta`; an open whose declaration matches it runs no DDL at all.
9
+ */
10
+ import type { DatabaseConfig } from '../contract/config';
11
+ import type { CollectionSchema } from '../contract/schema';
12
+ import { ensureAttachmentTable } from '../artifacts/store';
13
+ import type { Connection } from '../sqlite/connection';
14
+ import {
15
+ addColumnSql,
16
+ createIndexSql,
17
+ createTableSql,
18
+ physicalIndexName,
19
+ } from '../sqlite/row-codec';
20
+ import { text } from '../sqlite/values';
21
+
22
+ /** Bumped whenever the DDL this package generates changes, so it is re-applied once. */
23
+ const DDL_VERSION = 1;
24
+ const FINGERPRINT_KEY = 'schema_fingerprint';
25
+
26
+ /** What was declared, and by which generation of this package's DDL. */
27
+ function fingerprintOf(config: DatabaseConfig): string {
28
+ return JSON.stringify([DDL_VERSION, config.collections]);
29
+ }
30
+
31
+ async function appliedFingerprint(connection: Connection): Promise<string | null> {
32
+ try {
33
+ const result = await connection.execute('SELECT value FROM _meta WHERE key = ?', [
34
+ FINGERPRINT_KEY,
35
+ ]);
36
+ const row = result.rows[0];
37
+ return row ? text(row.value) : null;
38
+ } catch {
39
+ // A database that has never been opened has no `_meta` yet.
40
+ return null;
41
+ }
42
+ }
43
+
44
+ /** `unique` and the column list of an index, read back from the statement SQLite stored. */
45
+ function definitionOf(sql: string): string {
46
+ const unique = /^\s*CREATE\s+UNIQUE\s/i.test(sql);
47
+ const columns = sql.slice(sql.lastIndexOf('(') + 1, sql.lastIndexOf(')')).replace(/["\s]/g, '');
48
+ return `${unique ? 'unique' : 'plain'}:${columns}`;
49
+ }
50
+
51
+ /**
52
+ * Indexes follow their declaration (LDB-11). `CREATE INDEX IF NOT EXISTS` keyed
53
+ * on the name alone meant that adding `unique: true` to an existing index, or
54
+ * changing its fields, did nothing, and an index no longer declared stayed
55
+ * forever. An index whose definition changed is dropped and recreated, and one
56
+ * of this package's that is no longer declared is dropped. Only indexes carrying
57
+ * the collection's own prefix are touched.
58
+ */
59
+ async function syncIndexes(connection: Connection, schema: CollectionSchema): Promise<void> {
60
+ const table = `c_${schema.name}`;
61
+ const existing = await connection.execute(
62
+ "SELECT name, sql FROM sqlite_master WHERE type = 'index' AND tbl_name = ? AND sql IS NOT NULL",
63
+ [table],
64
+ );
65
+ const current = new Map(existing.rows.map(row => [text(row.name), text(row.sql)]));
66
+ const wanted = new Map(
67
+ createIndexSql(schema).map(statement => {
68
+ const name = /INDEX IF NOT EXISTS "([^"]+)"/.exec(statement)?.[1] ?? '';
69
+ return [name, statement] as const;
70
+ }),
71
+ );
72
+
73
+ for (const index of schema.indexes ?? []) {
74
+ // Indexes created before 0.2.0 carry the bare declared name; they are
75
+ // recreated under the collection-scoped one. No row data moves.
76
+ if (current.has(index.name) && index.name !== physicalIndexName(schema, index.name)) {
77
+ await connection.execute(`DROP INDEX IF EXISTS "${index.name}"`);
78
+ }
79
+ }
80
+ for (const [name, sql] of current) {
81
+ const statement = wanted.get(name);
82
+ const ours = name.startsWith(`${table}__`);
83
+ if (
84
+ (statement === undefined && ours) ||
85
+ (statement && definitionOf(statement) !== definitionOf(sql))
86
+ ) {
87
+ await connection.execute(`DROP INDEX IF EXISTS "${name}"`);
88
+ }
89
+ }
90
+ for (const statement of wanted.values()) {
91
+ await connection.execute(statement);
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Adding a column rather than recreating the table is what makes declaring a
97
+ * new queryable field non-destructive: the payload already holds the value, so
98
+ * the new column is backfilled from it and no record is rewritten by hand
99
+ * (research.md R6).
100
+ */
101
+ async function ensureCollection(connection: Connection, schema: CollectionSchema): Promise<void> {
102
+ await connection.execute(createTableSql(schema));
103
+
104
+ const info = await connection.execute(`PRAGMA table_info("c_${schema.name}")`);
105
+ const present = new Set(info.rows.map(row => text(row.name)));
106
+
107
+ for (const field of schema.fields ?? []) {
108
+ const column = `f_${field.name}`;
109
+ if (present.has(column)) {
110
+ continue;
111
+ }
112
+ await connection.execute(addColumnSql(schema, field));
113
+ // The same projection a write applies (`projectValue`): an object or an
114
+ // array projects to NULL. A bare json_extract backfilled those as JSON
115
+ // text, so a filter matched old records and new ones differently (LDB-11).
116
+ const path = `'$."${field.name}"'`;
117
+ await connection.execute(
118
+ `UPDATE "c_${schema.name}" SET "${column}" = CASE WHEN json_type(payload, ${path}) IN ('object', 'array') THEN NULL ELSE json_extract(payload, ${path}) END`,
119
+ );
120
+ }
121
+
122
+ await syncIndexes(connection, schema);
123
+ }
124
+
125
+ export interface SchemaSync {
126
+ /** True when the stored schema already matched, so `_meta` exists and no DDL ran. */
127
+ readonly unchanged: boolean;
128
+ /** Records the declaration as applied. Call once the open has succeeded. */
129
+ record(): Promise<void>;
130
+ }
131
+
132
+ export async function syncSchema(
133
+ connection: Connection,
134
+ config: DatabaseConfig,
135
+ ): Promise<SchemaSync> {
136
+ const fingerprint = fingerprintOf(config);
137
+ if ((await appliedFingerprint(connection)) === fingerprint) {
138
+ return { unchanged: true, record: async () => undefined };
139
+ }
140
+
141
+ for (const schema of config.collections) {
142
+ await ensureCollection(connection, schema);
143
+ }
144
+ await ensureAttachmentTable(connection);
145
+
146
+ return {
147
+ unchanged: false,
148
+ async record() {
149
+ await connection.execute(
150
+ 'INSERT INTO _meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value',
151
+ [FINGERPRINT_KEY, fingerprint],
152
+ );
153
+ },
154
+ };
155
+ }
@@ -19,7 +19,6 @@ import type { Executor } from '../sqlite/connection';
19
19
  import { artifactPath } from './paths';
20
20
  import {
21
21
  deleteReference,
22
- listReferences,
23
22
  listReferencesFor,
24
23
  type ArtifactContext,
25
24
  type DeferredUnlinks,
@@ -99,16 +98,3 @@ export async function cascadeForOwner(
99
98
  deferred,
100
99
  );
101
100
  }
102
-
103
- /** Everything in the scope, for scope deletion. */
104
- export async function cascadeAll(
105
- context: ArtifactContext,
106
- executor: Executor,
107
- ): Promise<DeletionOutcome> {
108
- const references = await listReferences(executor);
109
- return removeAll(
110
- context,
111
- executor,
112
- references.map(reference => reference.id),
113
- );
114
- }
@@ -152,6 +152,12 @@ async function writePlaintext(path: string, content: string): Promise<void> {
152
152
  await fileSystem().writeFile(path, content, 'base64');
153
153
  }
154
154
 
155
+ /** Where an artifact's bytes come from: how many, and any one chunk of them. */
156
+ type ByteSource = {
157
+ size: number;
158
+ chunk(offset: number, length: number): Promise<Uint8Array>;
159
+ };
160
+
155
161
  /**
156
162
  * Encrypted write, one chunk in flight.
157
163
  *
@@ -159,43 +165,87 @@ async function writePlaintext(path: string, content: string): Promise<void> {
159
165
  * plaintext length — are all known from the input length before any
160
166
  * encryption happens. Only the tags depend on the ciphertext, and they follow
161
167
  * their own chunk, so the whole write is append-only and peak memory is one
162
- * chunk regardless of artifact size (FR-107a).
168
+ * chunk regardless of artifact size when the source is a file (FR-107a).
163
169
  */
164
170
  async function writeEncrypted(
165
171
  path: string,
166
172
  key: Uint8Array,
167
- plaintext: Uint8Array,
173
+ source: ByteSource,
168
174
  ): Promise<{ chunkSize: number; chunkCount: number }> {
169
- const header = headerFor(plaintext.length, Budgets.ARTIFACT_CHUNK_BYTES);
175
+ const header = headerFor(source.size, Budgets.ARTIFACT_CHUNK_BYTES);
170
176
  await fileSystem().writeFile(path, bytesToBase64(encodeHeader(header)), 'base64');
171
177
 
172
178
  for (let index = 0; index < header.chunkCount; index += 1) {
173
- const start = index * header.chunkSize;
174
- const chunk = plaintext.subarray(start, start + chunkPlaintextLength(header, index));
179
+ const chunk = await source.chunk(index * header.chunkSize, chunkPlaintextLength(header, index));
175
180
  await fileSystem().appendFile(path, bytesToBase64(encryptChunk(key, index, chunk)), 'base64');
176
181
  }
177
182
 
178
183
  return { chunkSize: header.chunkSize, chunkCount: header.chunkCount };
179
184
  }
180
185
 
186
+ function sourceFileOf(input: AttachmentInput): string | null {
187
+ if (typeof input.sourcePath !== 'string') {
188
+ return null;
189
+ }
190
+ return input.sourcePath.startsWith('file://')
191
+ ? input.sourcePath.slice('file://'.length)
192
+ : input.sourcePath;
193
+ }
194
+
195
+ /**
196
+ * The input as something to read from. A base64 `content` is decoded whole,
197
+ * which is the cost of that form; a `sourcePath` is only measured here and
198
+ * read a chunk at a time, or not read by JS at all (LDB-09).
199
+ */
200
+ async function byteSourceFor(input: AttachmentInput): Promise<ByteSource> {
201
+ const file = sourceFileOf(input);
202
+ if (file === null) {
203
+ if (typeof input.content !== 'string') {
204
+ raise(DiagnosticCodes.CONFIG_INVALID, {
205
+ message: 'an attachment needs content or sourcePath',
206
+ detail: { attachmentId: input.id },
207
+ });
208
+ }
209
+ const plaintext = base64ToBytes(input.content);
210
+ return {
211
+ size: plaintext.length,
212
+ chunk: async (offset, length) => plaintext.subarray(offset, offset + length),
213
+ };
214
+ }
215
+ let size: number;
216
+ try {
217
+ size = (await fileSystem().stat(file)).size;
218
+ } catch (error) {
219
+ raiseNative(error, { operation: 'attachment' });
220
+ }
221
+ return {
222
+ size,
223
+ chunk: async (offset, length) =>
224
+ base64ToBytes(await fileSystem().read(file, length, offset, 'base64')),
225
+ };
226
+ }
227
+
181
228
  export async function writeArtifact(
182
229
  context: ArtifactContext,
183
230
  input: AttachmentInput,
184
231
  ): Promise<AttachmentReference> {
185
232
  throwIfFaulted('attachmentWrite');
186
- const plaintext = base64ToBytes(input.content);
187
- assertArtifactWithinBudget(input.id, plaintext.length);
233
+ const source = await byteSourceFor(input);
234
+ assertArtifactWithinBudget(input.id, source.size);
188
235
 
189
236
  const path = artifactPath(context.layout, context.scopeKey, input.id);
190
237
  const key = context.artifactKey(input.id);
238
+ const file = sourceFileOf(input);
191
239
 
192
240
  let chunking: { chunkSize: number; chunkCount: number } | undefined;
193
241
  try {
194
242
  await fileSystem().mkdir(artifactsDirectory(context.layout, context.scopeKey));
195
243
  if (key) {
196
- chunking = await writeEncrypted(path, key, plaintext);
244
+ chunking = await writeEncrypted(path, key, source);
245
+ } else if (file !== null) {
246
+ await fileSystem().copyFile(file, path);
197
247
  } else {
198
- await writePlaintext(path, input.content);
248
+ await writePlaintext(path, input.content as string);
199
249
  }
200
250
  } catch (error) {
201
251
  // A partial file with no reference is an orphan reconciliation can
@@ -207,7 +257,7 @@ export async function writeArtifact(
207
257
  const reference: AttachmentReference = {
208
258
  id: input.id,
209
259
  contentType: input.contentType,
210
- size: plaintext.length,
260
+ size: source.size,
211
261
  createdAt: context.now(),
212
262
  };
213
263
  if (input.owner) {
@@ -11,18 +11,39 @@ export type AttachmentOwner = {
11
11
  id: string;
12
12
  };
13
13
 
14
- export type AttachmentInput = {
14
+ type AttachmentDescription = {
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
- /** Base64 content. Written incrementally; never held whole when encrypted. */
20
- content: string;
21
19
  };
22
20
 
21
+ /**
22
+ * Where the bytes come from. Exactly one of the two.
23
+ *
24
+ * `sourcePath` is the one to use for anything large — a photo, a recording.
25
+ * The file is copied (or, when encrypted, read and encrypted 256 KB at a
26
+ * time), so memory does not grow with its size. `content` holds the whole
27
+ * payload in JS as base64 and is decoded in one piece: fine for a thumbnail,
28
+ * several times the file's size in memory for a video.
29
+ */
30
+ export type AttachmentInput = AttachmentDescription &
31
+ (
32
+ | {
33
+ /** The complete content, base64. Held and decoded whole. */
34
+ content: string;
35
+ sourcePath?: undefined;
36
+ }
37
+ | {
38
+ /** Absolute path of a file the app can read. A `file://` prefix is accepted. */
39
+ sourcePath: string;
40
+ content?: undefined;
41
+ }
42
+ );
43
+
23
44
  export type AttachmentReference = {
24
45
  id: string;
25
- owner?: AttachmentOwner;
46
+ owner?: AttachmentOwner | undefined;
26
47
  contentType: string;
27
48
  /** Plaintext byte length, <= 100 MB (FR-088). */
28
49
  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 = {
@@ -10,9 +10,12 @@ import type { Transaction } from './transaction';
10
10
  /** Low-level escape hatch for schema work a migration needs. */
11
11
  // Attachments are not part of a migration: a migration reshapes records, and
12
12
  // files written inside one would survive its rollback with nothing to own them.
13
+ /** A value SQLite can bind. */
14
+ export type SqlParameter = string | number | boolean | null;
15
+
13
16
  export type MigrationContext = Omit<Transaction, 'attachments'> & {
14
17
  /** Execute a raw statement against the scope's database. */
15
- execute(sql: string, params?: unknown[]): Promise<void>;
18
+ execute(sql: string, params?: SqlParameter[]): Promise<void>;
16
19
  };
17
20
 
18
21
  export type Migration = {
@@ -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
  }
@@ -54,17 +73,22 @@ export function isLogging(): boolean {
54
73
  * installed and the build is a development build, so a production bundle pays
55
74
  * nothing and emits nothing (FR-081).
56
75
  */
57
- export function instrument<T extends Record<string, (...args: never[]) => unknown>>(
76
+ export function instrument<T extends object>(
58
77
  collection: string,
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
 
66
86
  const wrapped: Record<string, unknown> = {};
67
87
  for (const [name, operation] of Object.entries(operations)) {
88
+ if (typeof operation !== 'function') {
89
+ wrapped[name] = operation;
90
+ continue;
91
+ }
68
92
  wrapped[name] = async (...args: never[]): Promise<unknown> => {
69
93
  const started = Date.now();
70
94
  try {
@@ -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. */