@chipmobilesdk/rn-local-db 0.2.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +92 -1
- package/README.md +31 -12
- package/package.json +5 -2
- package/skills/sdk-rn-local-db/SKILL.md +1 -1
- package/src/adapter/budgets.ts +1 -10
- package/src/adapter/collection.ts +38 -66
- package/src/adapter/deletion.ts +48 -0
- package/src/adapter/handle.ts +311 -0
- package/src/adapter/lifecycle.ts +10 -338
- package/src/adapter/schema-sync.ts +155 -0
- package/src/artifacts/cascade.ts +0 -14
- package/src/artifacts/store.ts +60 -10
- package/src/contract/attachment.ts +26 -5
- package/src/contract/config.ts +2 -2
- package/src/contract/migration.ts +4 -1
- package/src/contract/query.ts +6 -6
- package/src/crypto/provider.ts +21 -27
- package/src/diagnostics/dev-log.ts +28 -4
- package/src/diagnostics/errors.ts +17 -16
- package/src/diagnostics/redact.ts +5 -23
- package/src/migration/executor.ts +27 -6
- package/src/migration/ledger.ts +18 -27
- package/src/platform/backup-ios.ts +4 -3
- package/src/platform/bytes.ts +21 -8
- package/src/platform/fs.ts +22 -27
- package/src/platform/location.ts +0 -3
- package/src/query/budgets.ts +32 -5
- package/src/query/cursor.ts +5 -5
- package/src/schema/validate-config.ts +3 -53
- package/src/sqlite/batch.ts +40 -51
- package/src/sqlite/connection.ts +0 -9
- package/src/sqlite/native.ts +20 -19
- package/src/sqlite/row-codec.ts +5 -2
- package/src/sqlite/statements.ts +74 -0
- package/src/testing/conformance/attachments.ts +5 -4
- package/src/testing/conformance/encryption.ts +3 -2
- package/src/testing/conformance/index.ts +1 -1
- package/src/testing/index.ts +2 -0
- package/src/testing/memory-adapter/attachments.ts +146 -0
- package/src/testing/memory-adapter/index.ts +13 -110
- package/src/testing/memory-adapter/store.ts +3 -3
|
@@ -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
|
+
}
|
package/src/artifacts/cascade.ts
CHANGED
|
@@ -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
|
-
}
|
package/src/artifacts/store.ts
CHANGED
|
@@ -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
|
-
|
|
173
|
+
source: ByteSource,
|
|
168
174
|
): Promise<{ chunkSize: number; chunkCount: number }> {
|
|
169
|
-
const header = headerFor(
|
|
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
|
|
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
|
|
187
|
-
assertArtifactWithinBudget(input.id,
|
|
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,
|
|
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:
|
|
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
|
-
|
|
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;
|
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 = {
|
|
@@ -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?:
|
|
18
|
+
execute(sql: string, params?: SqlParameter[]): Promise<void>;
|
|
16
19
|
};
|
|
17
20
|
|
|
18
21
|
export type Migration = {
|
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
|
}
|
|
@@ -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
|
|
76
|
+
export function instrument<T extends object>(
|
|
58
77
|
collection: string,
|
|
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
|
|
|
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
|
-
|
|
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. */
|