@chipmobilesdk/rn-local-db 0.1.2 → 0.2.1
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 +122 -52
- package/README.md +663 -661
- package/package.json +59 -62
- package/skills/sdk-rn-local-db/SKILL.md +0 -1
- package/src/adapter/budgets.ts +67 -67
- package/src/adapter/collection.ts +303 -298
- package/src/adapter/deletion.ts +252 -238
- package/src/adapter/index.ts +64 -68
- package/src/adapter/lifecycle.ts +517 -444
- package/src/adapter/transaction.ts +71 -65
- package/src/artifacts/availability.ts +67 -69
- package/src/artifacts/cascade.ts +114 -78
- package/src/artifacts/paths.ts +29 -29
- package/src/artifacts/reconcile.ts +63 -63
- package/src/artifacts/store.ts +334 -323
- package/src/contract/adapter.ts +76 -78
- package/src/contract/attachment.ts +75 -72
- package/src/contract/backup.ts +28 -28
- package/src/contract/config.ts +54 -54
- package/src/contract/encryption.ts +34 -34
- package/src/contract/errors.ts +76 -76
- package/src/contract/migration.ts +42 -40
- package/src/contract/query.ts +81 -81
- package/src/contract/record.ts +46 -42
- package/src/contract/schema.ts +36 -36
- package/src/contract/scope.ts +36 -36
- package/src/contract/transaction.ts +52 -46
- package/src/crypto/chunked-cipher.ts +153 -144
- package/src/crypto/derive.ts +64 -63
- package/src/crypto/key-provider.ts +78 -78
- package/src/crypto/provider.ts +81 -81
- package/src/diagnostics/dev-log.ts +112 -108
- package/src/diagnostics/errors.ts +62 -66
- package/src/diagnostics/faults.ts +58 -64
- package/src/diagnostics/map-native.ts +108 -106
- package/src/diagnostics/redact.ts +137 -141
- package/src/index.ts +80 -110
- package/src/migration/executor.ts +132 -136
- package/src/migration/ledger.ts +95 -97
- package/src/migration/validate-chain.ts +65 -65
- package/src/platform/backup-android-verify.ts +88 -88
- package/src/platform/backup-ios.ts +48 -48
- package/src/platform/bytes.ts +144 -0
- package/src/platform/fs.ts +89 -89
- package/src/platform/location.ts +135 -135
- package/src/query/budgets.ts +54 -57
- package/src/query/compare.ts +91 -91
- package/src/query/cursor.ts +100 -99
- package/src/query/filter.ts +82 -82
- package/src/query/paginate.ts +51 -51
- package/src/query/sort.ts +55 -56
- package/src/query/validate.ts +171 -171
- package/src/schema/declare.ts +42 -45
- package/src/schema/validate-config.ts +158 -158
- package/src/scope/hash.ts +131 -134
- package/src/scope/normalize.ts +83 -90
- package/src/scope/validate.ts +83 -83
- package/src/sqlite/adapter.ts +26 -26
- package/src/sqlite/batch.ts +242 -232
- package/src/sqlite/catalog.ts +105 -108
- package/src/sqlite/connection.ts +227 -205
- package/src/sqlite/index-coverage.ts +54 -53
- package/src/sqlite/native.ts +83 -83
- package/src/sqlite/row-codec.ts +217 -201
- package/src/sqlite/sql-builder.ts +244 -244
- package/src/sqlite/transaction-queue.ts +87 -0
- package/src/sqlite/transactions.ts +33 -33
- package/src/sqlite/values.ts +24 -24
- package/src/testing/conformance/attachments.ts +138 -136
- package/src/testing/conformance/encryption.ts +150 -150
- package/src/testing/conformance/index.ts +161 -161
- package/src/testing/conformance/lifecycle.ts +99 -99
- package/src/testing/conformance/migrations.ts +177 -177
- package/src/testing/conformance/queries.ts +204 -167
- package/src/testing/conformance/records.ts +169 -169
- package/src/testing/conformance/scopes.ts +86 -88
- package/src/testing/conformance/transactions.ts +149 -149
- package/src/testing/faults.ts +21 -21
- package/src/testing/index.ts +17 -21
- package/src/testing/memory-adapter/collection.ts +214 -219
- package/src/testing/memory-adapter/index.ts +510 -388
- package/src/testing/memory-adapter/migrations.ts +117 -119
- package/src/testing/memory-adapter/reset.ts +50 -50
- package/src/testing/memory-adapter/store.ts +128 -134
|
@@ -1,64 +1,58 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The fault registry.
|
|
3
|
-
*
|
|
4
|
-
* It lives here rather than under `testing/` because FR-095 wants every
|
|
5
|
-
* documented failure reachable from a real app running the real adapter — the
|
|
6
|
-
* demo's fault-injection panel exercises the on-device path, not a simulation
|
|
7
|
-
* of it. So the check has to exist in production code.
|
|
8
|
-
*
|
|
9
|
-
* What does *not* exist in production code is a way to inject: `injectFault`
|
|
10
|
-
* and `clearFaults` are exported only from the `./testing` entrypoint. With
|
|
11
|
-
* nothing injected this is one lookup in an empty map, which is the price of
|
|
12
|
-
* being able to prove a recovery path works before a user finds it broken.
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
import type { DiagnosticCode } from '../contract/errors';
|
|
16
|
-
import { StorageError } from './errors';
|
|
17
|
-
|
|
18
|
-
export type FaultPoint =
|
|
19
|
-
| '
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
fault.remaining -= 1;
|
|
60
|
-
if (fault.remaining <= 0) {
|
|
61
|
-
faults.delete(point);
|
|
62
|
-
}
|
|
63
|
-
throw new StorageError(fault.code, { message: `injected fault at ${point}` });
|
|
64
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* The fault registry.
|
|
3
|
+
*
|
|
4
|
+
* It lives here rather than under `testing/` because FR-095 wants every
|
|
5
|
+
* documented failure reachable from a real app running the real adapter — the
|
|
6
|
+
* demo's fault-injection panel exercises the on-device path, not a simulation
|
|
7
|
+
* of it. So the check has to exist in production code.
|
|
8
|
+
*
|
|
9
|
+
* What does *not* exist in production code is a way to inject: `injectFault`
|
|
10
|
+
* and `clearFaults` are exported only from the `./testing` entrypoint. With
|
|
11
|
+
* nothing injected this is one lookup in an empty map, which is the price of
|
|
12
|
+
* being able to prove a recovery path works before a user finds it broken.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { DiagnosticCode } from '../contract/errors';
|
|
16
|
+
import { StorageError } from './errors';
|
|
17
|
+
|
|
18
|
+
export type FaultPoint =
|
|
19
|
+
'open' | 'read' | 'write' | 'commit' | 'migration' | 'attachmentWrite' | 'keyProvider';
|
|
20
|
+
|
|
21
|
+
export type FaultSpec = {
|
|
22
|
+
at: FaultPoint;
|
|
23
|
+
code: DiagnosticCode;
|
|
24
|
+
/** Fire this many times, then stop. Omit for "until cleared". */
|
|
25
|
+
times?: number;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const faults = new Map<FaultPoint, { code: DiagnosticCode; remaining: number }>();
|
|
29
|
+
|
|
30
|
+
export function injectFault(spec: FaultSpec): void {
|
|
31
|
+
faults.set(spec.at, {
|
|
32
|
+
code: spec.code,
|
|
33
|
+
remaining: spec.times ?? Number.POSITIVE_INFINITY,
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function clearFaults(): void {
|
|
38
|
+
faults.clear();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function activeFaults(): FaultPoint[] {
|
|
42
|
+
return [...faults.keys()];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function throwIfFaulted(point: FaultPoint): void {
|
|
46
|
+
if (faults.size === 0) {
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const fault = faults.get(point);
|
|
50
|
+
if (!fault || fault.remaining <= 0) {
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
fault.remaining -= 1;
|
|
54
|
+
if (fault.remaining <= 0) {
|
|
55
|
+
faults.delete(point);
|
|
56
|
+
}
|
|
57
|
+
throw new StorageError(fault.code, { message: `injected fault at ${point}` });
|
|
58
|
+
}
|
|
@@ -1,106 +1,108 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Native error to diagnostic code.
|
|
3
|
-
*
|
|
4
|
-
* The three failures FR-076 insists stay apart — a full disk, a filesystem
|
|
5
|
-
* the app cannot reach, and a corrupt database — arrive from SQLite and from
|
|
6
|
-
* the filesystem peer as ordinary errors with a string on them. Collapsing
|
|
7
|
-
* them into one write failure would be the easy thing and the wrong one: an
|
|
8
|
-
* app frees space for the first, retries after unlock for the second, and has
|
|
9
|
-
* to offer a reset for the third.
|
|
10
|
-
*
|
|
11
|
-
* Only the code is derived here. The message never travels — it is redacted
|
|
12
|
-
* at construction by `StorageError` (FR-079).
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
import { DiagnosticCodes, type DiagnosticCode } from '../contract/errors';
|
|
16
|
-
import { isStorageError, raise } from './errors';
|
|
17
|
-
|
|
18
|
-
export type NativeContext = {
|
|
19
|
-
/** Where the failure happened, so ambiguous signatures resolve correctly. */
|
|
20
|
-
operation: 'open' | 'read' | 'write' | 'transaction' | 'attachment' | 'delete';
|
|
21
|
-
/**
|
|
22
|
-
* Whether the database was opened with a key. "File is not a database" is
|
|
23
|
-
* SQLCipher's answer to a wrong key and SQLite's answer to a corrupt file;
|
|
24
|
-
* the only thing that separates them is whether a key was in play.
|
|
25
|
-
*/
|
|
26
|
-
encrypted?: boolean;
|
|
27
|
-
};
|
|
28
|
-
|
|
29
|
-
function textOf(error: unknown): string {
|
|
30
|
-
if (error instanceof Error) {
|
|
31
|
-
return `${error.message} ${(error as { code?: string }).code ?? ''}`;
|
|
32
|
-
}
|
|
33
|
-
if (typeof error === 'string') {
|
|
34
|
-
return error;
|
|
35
|
-
}
|
|
36
|
-
return '';
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
const DISK_FULL = /SQLITE_FULL|database or disk is full|ENOSPC|no space left/i;
|
|
40
|
-
const NOT_A_DATABASE = /SQLITE_NOTADB|file is not a database|encrypted or is not a database/i;
|
|
41
|
-
const CORRUPT = /SQLITE_CORRUPT|database disk image is malformed|malformed database/i;
|
|
42
|
-
const CONFLICT =
|
|
43
|
-
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
//
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
case '
|
|
78
|
-
return DiagnosticCodes.
|
|
79
|
-
case '
|
|
80
|
-
return DiagnosticCodes.
|
|
81
|
-
case '
|
|
82
|
-
return DiagnosticCodes.
|
|
83
|
-
case '
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
//
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Native error to diagnostic code.
|
|
3
|
+
*
|
|
4
|
+
* The three failures FR-076 insists stay apart — a full disk, a filesystem
|
|
5
|
+
* the app cannot reach, and a corrupt database — arrive from SQLite and from
|
|
6
|
+
* the filesystem peer as ordinary errors with a string on them. Collapsing
|
|
7
|
+
* them into one write failure would be the easy thing and the wrong one: an
|
|
8
|
+
* app frees space for the first, retries after unlock for the second, and has
|
|
9
|
+
* to offer a reset for the third.
|
|
10
|
+
*
|
|
11
|
+
* Only the code is derived here. The message never travels — it is redacted
|
|
12
|
+
* at construction by `StorageError` (FR-079).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { DiagnosticCodes, type DiagnosticCode } from '../contract/errors';
|
|
16
|
+
import { isStorageError, raise } from './errors';
|
|
17
|
+
|
|
18
|
+
export type NativeContext = {
|
|
19
|
+
/** Where the failure happened, so ambiguous signatures resolve correctly. */
|
|
20
|
+
operation: 'open' | 'read' | 'write' | 'transaction' | 'attachment' | 'delete';
|
|
21
|
+
/**
|
|
22
|
+
* Whether the database was opened with a key. "File is not a database" is
|
|
23
|
+
* SQLCipher's answer to a wrong key and SQLite's answer to a corrupt file;
|
|
24
|
+
* the only thing that separates them is whether a key was in play.
|
|
25
|
+
*/
|
|
26
|
+
encrypted?: boolean;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
function textOf(error: unknown): string {
|
|
30
|
+
if (error instanceof Error) {
|
|
31
|
+
return `${error.message} ${(error as { code?: string }).code ?? ''}`;
|
|
32
|
+
}
|
|
33
|
+
if (typeof error === 'string') {
|
|
34
|
+
return error;
|
|
35
|
+
}
|
|
36
|
+
return '';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const DISK_FULL = /SQLITE_FULL|database or disk is full|ENOSPC|no space left/i;
|
|
40
|
+
const NOT_A_DATABASE = /SQLITE_NOTADB|file is not a database|encrypted or is not a database/i;
|
|
41
|
+
const CORRUPT = /SQLITE_CORRUPT|database disk image is malformed|malformed database/i;
|
|
42
|
+
const CONFLICT =
|
|
43
|
+
/UNIQUE constraint failed|PRIMARY KEY must be unique|SQLITE_CONSTRAINT_PRIMARYKEY/i;
|
|
44
|
+
const ACCESS =
|
|
45
|
+
/SQLITE_CANTOPEN|SQLITE_READONLY|SQLITE_PERM|SQLITE_AUTH|EACCES|EPERM|EROFS|ENOENT|EISDIR/i;
|
|
46
|
+
const BUSY = /SQLITE_BUSY|SQLITE_LOCKED|database is locked/i;
|
|
47
|
+
|
|
48
|
+
export function mapNativeError(error: unknown, context: NativeContext): DiagnosticCode {
|
|
49
|
+
const text = textOf(error);
|
|
50
|
+
|
|
51
|
+
if (DISK_FULL.test(text)) {
|
|
52
|
+
return DiagnosticCodes.DISK_FULL;
|
|
53
|
+
}
|
|
54
|
+
if (NOT_A_DATABASE.test(text)) {
|
|
55
|
+
// A wrong key and a corrupt file are indistinguishable from the engine's
|
|
56
|
+
// answer alone. Reporting corruption for a wrong key would push an app
|
|
57
|
+
// towards destroying recoverable data, so the encrypted case wins.
|
|
58
|
+
return context.encrypted ? DiagnosticCodes.KEY_INVALID : DiagnosticCodes.CORRUPTION;
|
|
59
|
+
}
|
|
60
|
+
if (CORRUPT.test(text)) {
|
|
61
|
+
return DiagnosticCodes.CORRUPTION;
|
|
62
|
+
}
|
|
63
|
+
if (CONFLICT.test(text)) {
|
|
64
|
+
return DiagnosticCodes.RECORD_CONFLICT;
|
|
65
|
+
}
|
|
66
|
+
if (ACCESS.test(text)) {
|
|
67
|
+
return DiagnosticCodes.STORAGE_ACCESS;
|
|
68
|
+
}
|
|
69
|
+
if (BUSY.test(text)) {
|
|
70
|
+
// Retryable, and distinct from a permanent write failure.
|
|
71
|
+
return context.operation === 'transaction'
|
|
72
|
+
? DiagnosticCodes.TRANSACTION_FAILED
|
|
73
|
+
: DiagnosticCodes.WRITE_FAILED;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
switch (context.operation) {
|
|
77
|
+
case 'open':
|
|
78
|
+
return DiagnosticCodes.OPEN_FAILED;
|
|
79
|
+
case 'read':
|
|
80
|
+
return DiagnosticCodes.STORAGE_ACCESS;
|
|
81
|
+
case 'transaction':
|
|
82
|
+
return DiagnosticCodes.TRANSACTION_FAILED;
|
|
83
|
+
case 'attachment':
|
|
84
|
+
return DiagnosticCodes.ATTACHMENT_WRITE_FAILED;
|
|
85
|
+
case 'delete':
|
|
86
|
+
case 'write':
|
|
87
|
+
default:
|
|
88
|
+
return DiagnosticCodes.WRITE_FAILED;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Re-raise a native failure as a typed one, preserving an error the package
|
|
94
|
+
* already classified rather than reclassifying it as a generic write failure.
|
|
95
|
+
*/
|
|
96
|
+
export function raiseNative(error: unknown, context: NativeContext): never {
|
|
97
|
+
if (isStorageError(error)) {
|
|
98
|
+
throw error;
|
|
99
|
+
}
|
|
100
|
+
raise(mapNativeError(error, context), {
|
|
101
|
+
// The native message is deliberately not the error's message. It goes
|
|
102
|
+
// through `cause`, which redacts it — passing it here as well would be
|
|
103
|
+
// two chances to leak the same string for no extra information.
|
|
104
|
+
message: `native ${context.operation} failed`,
|
|
105
|
+
detail: { operation: context.operation },
|
|
106
|
+
cause: error,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
@@ -1,141 +1,137 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Redaction, applied at construction rather than at logging time.
|
|
3
|
-
*
|
|
4
|
-
* The distinction matters: an error object that never held a payload cannot
|
|
5
|
-
* leak one through a consumer's crash reporter, which is where these leaks
|
|
6
|
-
* actually happen in practice. Redacting on the way out of a logger would
|
|
7
|
-
* still leave the values sitting on the object for anything else to pick up
|
|
8
|
-
* (FR-079, FR-099).
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* The allowlist IS the mechanism. Every key here has been reviewed as
|
|
13
|
-
* incapable of carrying user data: identifiers the app already knows, sizes,
|
|
14
|
-
* counts, and version numbers.
|
|
15
|
-
*
|
|
16
|
-
* A denylist was tried and removed. It rejected `recordId` because the name
|
|
17
|
-
* contains "record", which is the failure mode denylists always have — they
|
|
18
|
-
* guess from names. A closed allowlist cannot guess, and adding to it is a
|
|
19
|
-
* deliberate act with a reviewer attached.
|
|
20
|
-
*/
|
|
21
|
-
export const SAFE_DETAIL_KEYS = new Set([
|
|
22
|
-
'code',
|
|
23
|
-
'collection',
|
|
24
|
-
'recordId',
|
|
25
|
-
'attachmentId',
|
|
26
|
-
'scopeKey',
|
|
27
|
-
'version',
|
|
28
|
-
'recordedVersion',
|
|
29
|
-
'failingVersion',
|
|
30
|
-
'outcome',
|
|
31
|
-
'operation',
|
|
32
|
-
'adapter',
|
|
33
|
-
'platform',
|
|
34
|
-
'sizeBytes',
|
|
35
|
-
'limitBytes',
|
|
36
|
-
'count',
|
|
37
|
-
'limit',
|
|
38
|
-
]);
|
|
39
|
-
|
|
40
|
-
const REDACTED = '[redacted]';
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* Reduce foreign text to an identifier, or to nothing.
|
|
44
|
-
*
|
|
45
|
-
* "Foreign" means text this package did not author: a SQLite message, a
|
|
46
|
-
* filesystem error, an `Error` a consumer's own migration threw. Any of them
|
|
47
|
-
* can contain anything — SQLite quotes bound parameters back, and a migration
|
|
48
|
-
* that fails on a constraint routinely names the value that violated it.
|
|
49
|
-
*
|
|
50
|
-
* An earlier version of this tried to keep the readable part: the text before
|
|
51
|
-
* the first colon, if it "looked like a class of failure". That heuristic
|
|
52
|
-
* fails on the case that matters. `Patient reported chest pain` looks exactly
|
|
53
|
-
* like a class of failure — short, no punctuation, no digits — and is a
|
|
54
|
-
* medical record. There is no shape test that separates prose describing a
|
|
55
|
-
* failure from prose describing a person, so there is no version of the
|
|
56
|
-
* heuristic worth keeping.
|
|
57
|
-
*
|
|
58
|
-
* What survives is a leading `SQLITE_*` / `ENOENT`-style identifier, which is
|
|
59
|
-
* a closed vocabulary of engine and libc constants and cannot be a value.
|
|
60
|
-
* Everything else becomes `[redacted]`. A developer who needs more can
|
|
61
|
-
* reproduce with the development logger; a crash report cannot.
|
|
62
|
-
*/
|
|
63
|
-
const IDENTIFIER_TOKEN = /^([A-Z][A-Z0-9_]{2,})\b/;
|
|
64
|
-
|
|
65
|
-
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;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
const MAX_AUTHORED_MESSAGE = 160;
|
|
75
|
-
|
|
76
|
-
/**
|
|
77
|
-
* A message this package wrote itself.
|
|
78
|
-
*
|
|
79
|
-
* Every `raise()` call site passes a string literal, and the handful that
|
|
80
|
-
* interpolate do so with a value from a closed set — an operator name, a
|
|
81
|
-
* fault point, a sanitized identifier. `authored-messages.test.ts` enforces
|
|
82
|
-
* that mechanically against the source, so this is a checked property rather
|
|
83
|
-
* than a convention someone has to remember.
|
|
84
|
-
*
|
|
85
|
-
* So no filtering happens here: filtering a message the package wrote would
|
|
86
|
-
* destroy the only readable part of the error for no gain. Foreign text never
|
|
87
|
-
* reaches this function — it goes through `redactForeignText` above.
|
|
88
|
-
*/
|
|
89
|
-
export function sanitizeAuthoredMessage(message: string): string {
|
|
90
|
-
return message.split('\n')[0]!.trim().slice(0, MAX_AUTHORED_MESSAGE);
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/**
|
|
94
|
-
* Reduce a consumer-supplied identifier to a bounded, punctuation-free token.
|
|
95
|
-
*
|
|
96
|
-
* A field name is worth naming — FR-034 exists so a typo is distinguishable
|
|
97
|
-
* from a deliberate choice, and "which field?" is the whole answer. But the
|
|
98
|
-
* name arrives from the consumer, so it is capped and stripped rather than
|
|
99
|
-
* trusted verbatim.
|
|
100
|
-
*/
|
|
101
|
-
export function sanitizeIdentifier(identifier: string): string {
|
|
102
|
-
const cleaned = identifier.replace(/\W/g, '').slice(0, 64);
|
|
103
|
-
return cleaned.length > 0 ? cleaned : REDACTED;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
/** Keep only keys on the allowlist, and only primitive values. */
|
|
107
|
-
export function redactDetail(
|
|
108
|
-
detail: Record<string, unknown> | undefined,
|
|
109
|
-
): 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 (
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
return redactForeignText(cause);
|
|
139
|
-
}
|
|
140
|
-
return REDACTED;
|
|
141
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Redaction, applied at construction rather than at logging time.
|
|
3
|
+
*
|
|
4
|
+
* The distinction matters: an error object that never held a payload cannot
|
|
5
|
+
* leak one through a consumer's crash reporter, which is where these leaks
|
|
6
|
+
* actually happen in practice. Redacting on the way out of a logger would
|
|
7
|
+
* still leave the values sitting on the object for anything else to pick up
|
|
8
|
+
* (FR-079, FR-099).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The allowlist IS the mechanism. Every key here has been reviewed as
|
|
13
|
+
* incapable of carrying user data: identifiers the app already knows, sizes,
|
|
14
|
+
* counts, and version numbers.
|
|
15
|
+
*
|
|
16
|
+
* A denylist was tried and removed. It rejected `recordId` because the name
|
|
17
|
+
* contains "record", which is the failure mode denylists always have — they
|
|
18
|
+
* guess from names. A closed allowlist cannot guess, and adding to it is a
|
|
19
|
+
* deliberate act with a reviewer attached.
|
|
20
|
+
*/
|
|
21
|
+
export const SAFE_DETAIL_KEYS = new Set([
|
|
22
|
+
'code',
|
|
23
|
+
'collection',
|
|
24
|
+
'recordId',
|
|
25
|
+
'attachmentId',
|
|
26
|
+
'scopeKey',
|
|
27
|
+
'version',
|
|
28
|
+
'recordedVersion',
|
|
29
|
+
'failingVersion',
|
|
30
|
+
'outcome',
|
|
31
|
+
'operation',
|
|
32
|
+
'adapter',
|
|
33
|
+
'platform',
|
|
34
|
+
'sizeBytes',
|
|
35
|
+
'limitBytes',
|
|
36
|
+
'count',
|
|
37
|
+
'limit',
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
const REDACTED = '[redacted]';
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Reduce foreign text to an identifier, or to nothing.
|
|
44
|
+
*
|
|
45
|
+
* "Foreign" means text this package did not author: a SQLite message, a
|
|
46
|
+
* filesystem error, an `Error` a consumer's own migration threw. Any of them
|
|
47
|
+
* can contain anything — SQLite quotes bound parameters back, and a migration
|
|
48
|
+
* that fails on a constraint routinely names the value that violated it.
|
|
49
|
+
*
|
|
50
|
+
* An earlier version of this tried to keep the readable part: the text before
|
|
51
|
+
* the first colon, if it "looked like a class of failure". That heuristic
|
|
52
|
+
* fails on the case that matters. `Patient reported chest pain` looks exactly
|
|
53
|
+
* like a class of failure — short, no punctuation, no digits — and is a
|
|
54
|
+
* medical record. There is no shape test that separates prose describing a
|
|
55
|
+
* failure from prose describing a person, so there is no version of the
|
|
56
|
+
* heuristic worth keeping.
|
|
57
|
+
*
|
|
58
|
+
* What survives is a leading `SQLITE_*` / `ENOENT`-style identifier, which is
|
|
59
|
+
* a closed vocabulary of engine and libc constants and cannot be a value.
|
|
60
|
+
* Everything else becomes `[redacted]`. A developer who needs more can
|
|
61
|
+
* reproduce with the development logger; a crash report cannot.
|
|
62
|
+
*/
|
|
63
|
+
const IDENTIFIER_TOKEN = /^([A-Z][A-Z0-9_]{2,})\b/;
|
|
64
|
+
|
|
65
|
+
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;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const MAX_AUTHORED_MESSAGE = 160;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* A message this package wrote itself.
|
|
78
|
+
*
|
|
79
|
+
* Every `raise()` call site passes a string literal, and the handful that
|
|
80
|
+
* interpolate do so with a value from a closed set — an operator name, a
|
|
81
|
+
* fault point, a sanitized identifier. `authored-messages.test.ts` enforces
|
|
82
|
+
* that mechanically against the source, so this is a checked property rather
|
|
83
|
+
* than a convention someone has to remember.
|
|
84
|
+
*
|
|
85
|
+
* So no filtering happens here: filtering a message the package wrote would
|
|
86
|
+
* destroy the only readable part of the error for no gain. Foreign text never
|
|
87
|
+
* reaches this function — it goes through `redactForeignText` above.
|
|
88
|
+
*/
|
|
89
|
+
export function sanitizeAuthoredMessage(message: string): string {
|
|
90
|
+
return message.split('\n')[0]!.trim().slice(0, MAX_AUTHORED_MESSAGE);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Reduce a consumer-supplied identifier to a bounded, punctuation-free token.
|
|
95
|
+
*
|
|
96
|
+
* A field name is worth naming — FR-034 exists so a typo is distinguishable
|
|
97
|
+
* from a deliberate choice, and "which field?" is the whole answer. But the
|
|
98
|
+
* name arrives from the consumer, so it is capped and stripped rather than
|
|
99
|
+
* trusted verbatim.
|
|
100
|
+
*/
|
|
101
|
+
export function sanitizeIdentifier(identifier: string): string {
|
|
102
|
+
const cleaned = identifier.replace(/\W/g, '').slice(0, 64);
|
|
103
|
+
return cleaned.length > 0 ? cleaned : REDACTED;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Keep only keys on the allowlist, and only primitive values. */
|
|
107
|
+
export function redactDetail(
|
|
108
|
+
detail: Record<string, unknown> | undefined,
|
|
109
|
+
): 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;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Reduce an unknown thrown value to a message-shaped string, redacted. */
|
|
126
|
+
export function redactCause(cause: unknown): string | undefined {
|
|
127
|
+
if (cause === undefined || cause === null) {
|
|
128
|
+
return undefined;
|
|
129
|
+
}
|
|
130
|
+
if (cause instanceof Error) {
|
|
131
|
+
return redactForeignText(cause.message);
|
|
132
|
+
}
|
|
133
|
+
if (typeof cause === 'string') {
|
|
134
|
+
return redactForeignText(cause);
|
|
135
|
+
}
|
|
136
|
+
return REDACTED;
|
|
137
|
+
}
|