@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.
Files changed (84) hide show
  1. package/CHANGELOG.md +122 -52
  2. package/README.md +663 -661
  3. package/package.json +59 -62
  4. package/skills/sdk-rn-local-db/SKILL.md +0 -1
  5. package/src/adapter/budgets.ts +67 -67
  6. package/src/adapter/collection.ts +303 -298
  7. package/src/adapter/deletion.ts +252 -238
  8. package/src/adapter/index.ts +64 -68
  9. package/src/adapter/lifecycle.ts +517 -444
  10. package/src/adapter/transaction.ts +71 -65
  11. package/src/artifacts/availability.ts +67 -69
  12. package/src/artifacts/cascade.ts +114 -78
  13. package/src/artifacts/paths.ts +29 -29
  14. package/src/artifacts/reconcile.ts +63 -63
  15. package/src/artifacts/store.ts +334 -323
  16. package/src/contract/adapter.ts +76 -78
  17. package/src/contract/attachment.ts +75 -72
  18. package/src/contract/backup.ts +28 -28
  19. package/src/contract/config.ts +54 -54
  20. package/src/contract/encryption.ts +34 -34
  21. package/src/contract/errors.ts +76 -76
  22. package/src/contract/migration.ts +42 -40
  23. package/src/contract/query.ts +81 -81
  24. package/src/contract/record.ts +46 -42
  25. package/src/contract/schema.ts +36 -36
  26. package/src/contract/scope.ts +36 -36
  27. package/src/contract/transaction.ts +52 -46
  28. package/src/crypto/chunked-cipher.ts +153 -144
  29. package/src/crypto/derive.ts +64 -63
  30. package/src/crypto/key-provider.ts +78 -78
  31. package/src/crypto/provider.ts +81 -81
  32. package/src/diagnostics/dev-log.ts +112 -108
  33. package/src/diagnostics/errors.ts +62 -66
  34. package/src/diagnostics/faults.ts +58 -64
  35. package/src/diagnostics/map-native.ts +108 -106
  36. package/src/diagnostics/redact.ts +137 -141
  37. package/src/index.ts +80 -110
  38. package/src/migration/executor.ts +132 -136
  39. package/src/migration/ledger.ts +95 -97
  40. package/src/migration/validate-chain.ts +65 -65
  41. package/src/platform/backup-android-verify.ts +88 -88
  42. package/src/platform/backup-ios.ts +48 -48
  43. package/src/platform/bytes.ts +144 -0
  44. package/src/platform/fs.ts +89 -89
  45. package/src/platform/location.ts +135 -135
  46. package/src/query/budgets.ts +54 -57
  47. package/src/query/compare.ts +91 -91
  48. package/src/query/cursor.ts +100 -99
  49. package/src/query/filter.ts +82 -82
  50. package/src/query/paginate.ts +51 -51
  51. package/src/query/sort.ts +55 -56
  52. package/src/query/validate.ts +171 -171
  53. package/src/schema/declare.ts +42 -45
  54. package/src/schema/validate-config.ts +158 -158
  55. package/src/scope/hash.ts +131 -134
  56. package/src/scope/normalize.ts +83 -90
  57. package/src/scope/validate.ts +83 -83
  58. package/src/sqlite/adapter.ts +26 -26
  59. package/src/sqlite/batch.ts +242 -232
  60. package/src/sqlite/catalog.ts +105 -108
  61. package/src/sqlite/connection.ts +227 -205
  62. package/src/sqlite/index-coverage.ts +54 -53
  63. package/src/sqlite/native.ts +83 -83
  64. package/src/sqlite/row-codec.ts +217 -201
  65. package/src/sqlite/sql-builder.ts +244 -244
  66. package/src/sqlite/transaction-queue.ts +87 -0
  67. package/src/sqlite/transactions.ts +33 -33
  68. package/src/sqlite/values.ts +24 -24
  69. package/src/testing/conformance/attachments.ts +138 -136
  70. package/src/testing/conformance/encryption.ts +150 -150
  71. package/src/testing/conformance/index.ts +161 -161
  72. package/src/testing/conformance/lifecycle.ts +99 -99
  73. package/src/testing/conformance/migrations.ts +177 -177
  74. package/src/testing/conformance/queries.ts +204 -167
  75. package/src/testing/conformance/records.ts +169 -169
  76. package/src/testing/conformance/scopes.ts +86 -88
  77. package/src/testing/conformance/transactions.ts +149 -149
  78. package/src/testing/faults.ts +21 -21
  79. package/src/testing/index.ts +17 -21
  80. package/src/testing/memory-adapter/collection.ts +214 -219
  81. package/src/testing/memory-adapter/index.ts +510 -388
  82. package/src/testing/memory-adapter/migrations.ts +117 -119
  83. package/src/testing/memory-adapter/reset.ts +50 -50
  84. 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
- | 'open'
20
- | 'read'
21
- | 'write'
22
- | 'commit'
23
- | 'migration'
24
- | 'attachmentWrite'
25
- | 'keyProvider';
26
-
27
- export type FaultSpec = {
28
- at: FaultPoint;
29
- code: DiagnosticCode;
30
- /** Fire this many times, then stop. Omit for "until cleared". */
31
- times?: number;
32
- };
33
-
34
- const faults = new Map<FaultPoint, { code: DiagnosticCode; remaining: number }>();
35
-
36
- export function injectFault(spec: FaultSpec): void {
37
- faults.set(spec.at, {
38
- code: spec.code,
39
- remaining: spec.times ?? Number.POSITIVE_INFINITY,
40
- });
41
- }
42
-
43
- export function clearFaults(): void {
44
- faults.clear();
45
- }
46
-
47
- export function activeFaults(): FaultPoint[] {
48
- return [...faults.keys()];
49
- }
50
-
51
- export function throwIfFaulted(point: FaultPoint): void {
52
- if (faults.size === 0) {
53
- return;
54
- }
55
- const fault = faults.get(point);
56
- if (!fault || fault.remaining <= 0) {
57
- return;
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 = /UNIQUE constraint failed|PRIMARY KEY must be unique|SQLITE_CONSTRAINT_PRIMARYKEY/i;
43
- const ACCESS = /SQLITE_CANTOPEN|SQLITE_READONLY|SQLITE_PERM|SQLITE_AUTH|EACCES|EPERM|EROFS|ENOENT|EISDIR/i;
44
- const BUSY = /SQLITE_BUSY|SQLITE_LOCKED|database is locked/i;
45
-
46
- export function mapNativeError(error: unknown, context: NativeContext): DiagnosticCode {
47
- const text = textOf(error);
48
-
49
- if (DISK_FULL.test(text)) {
50
- return DiagnosticCodes.DISK_FULL;
51
- }
52
- if (NOT_A_DATABASE.test(text)) {
53
- // A wrong key and a corrupt file are indistinguishable from the engine's
54
- // answer alone. Reporting corruption for a wrong key would push an app
55
- // towards destroying recoverable data, so the encrypted case wins.
56
- return context.encrypted ? DiagnosticCodes.KEY_INVALID : DiagnosticCodes.CORRUPTION;
57
- }
58
- if (CORRUPT.test(text)) {
59
- return DiagnosticCodes.CORRUPTION;
60
- }
61
- if (CONFLICT.test(text)) {
62
- return DiagnosticCodes.RECORD_CONFLICT;
63
- }
64
- if (ACCESS.test(text)) {
65
- return DiagnosticCodes.STORAGE_ACCESS;
66
- }
67
- if (BUSY.test(text)) {
68
- // Retryable, and distinct from a permanent write failure.
69
- return context.operation === 'transaction'
70
- ? DiagnosticCodes.TRANSACTION_FAILED
71
- : DiagnosticCodes.WRITE_FAILED;
72
- }
73
-
74
- switch (context.operation) {
75
- case 'open':
76
- return DiagnosticCodes.OPEN_FAILED;
77
- case 'read':
78
- return DiagnosticCodes.STORAGE_ACCESS;
79
- case 'transaction':
80
- return DiagnosticCodes.TRANSACTION_FAILED;
81
- case 'attachment':
82
- return DiagnosticCodes.ATTACHMENT_WRITE_FAILED;
83
- case 'delete':
84
- case 'write':
85
- default:
86
- return DiagnosticCodes.WRITE_FAILED;
87
- }
88
- }
89
-
90
- /**
91
- * Re-raise a native failure as a typed one, preserving an error the package
92
- * already classified rather than reclassifying it as a generic write failure.
93
- */
94
- export function raiseNative(error: unknown, context: NativeContext): never {
95
- if (isStorageError(error)) {
96
- throw error;
97
- }
98
- raise(mapNativeError(error, context), {
99
- // The native message is deliberately not the error's message. It goes
100
- // through `cause`, which redacts it — passing it here as well would be
101
- // two chances to leak the same string for no extra information.
102
- message: `native ${context.operation} failed`,
103
- detail: { operation: context.operation },
104
- cause: error,
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
- typeof value === 'string' ||
120
- typeof value === 'number' ||
121
- typeof value === 'boolean'
122
- ) {
123
- safe[key] = value;
124
- }
125
- }
126
- return Object.keys(safe).length > 0 ? safe : undefined;
127
- }
128
-
129
- /** Reduce an unknown thrown value to a message-shaped string, redacted. */
130
- export function redactCause(cause: unknown): string | undefined {
131
- if (cause === undefined || cause === null) {
132
- return undefined;
133
- }
134
- if (cause instanceof Error) {
135
- return redactForeignText(cause.message);
136
- }
137
- if (typeof cause === 'string') {
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
+ }