@chipmobilesdk/rn-local-db 0.2.0 → 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 -102
  2. package/README.md +728 -728
  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 -252
  8. package/src/adapter/index.ts +64 -68
  9. package/src/adapter/lifecycle.ts +517 -520
  10. package/src/adapter/transaction.ts +71 -71
  11. package/src/artifacts/availability.ts +67 -67
  12. package/src/artifacts/cascade.ts +114 -114
  13. package/src/artifacts/paths.ts +29 -29
  14. package/src/artifacts/reconcile.ts +63 -63
  15. package/src/artifacts/store.ts +334 -345
  16. package/src/contract/adapter.ts +76 -78
  17. package/src/contract/attachment.ts +75 -78
  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 -42
  23. package/src/contract/query.ts +81 -81
  24. package/src/contract/record.ts +46 -51
  25. package/src/contract/schema.ts +36 -36
  26. package/src/contract/scope.ts +36 -36
  27. package/src/contract/transaction.ts +52 -54
  28. package/src/crypto/chunked-cipher.ts +153 -150
  29. package/src/crypto/derive.ts +64 -64
  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 -144
  44. package/src/platform/fs.ts +89 -89
  45. package/src/platform/location.ts +135 -135
  46. package/src/query/budgets.ts +54 -54
  47. package/src/query/compare.ts +91 -91
  48. package/src/query/cursor.ts +100 -100
  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 -242
  60. package/src/sqlite/catalog.ts +105 -108
  61. package/src/sqlite/connection.ts +227 -227
  62. package/src/sqlite/index-coverage.ts +54 -56
  63. package/src/sqlite/native.ts +83 -83
  64. package/src/sqlite/row-codec.ts +217 -221
  65. package/src/sqlite/sql-builder.ts +244 -244
  66. package/src/sqlite/transaction-queue.ts +87 -87
  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 -492
  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,78 +1,78 @@
1
- /**
2
- * Key acquisition, scoped to one handle's lifetime.
3
- *
4
- * The package never generates, derives from its own material, persists,
5
- * caches beyond the handle, transmits, or returns a key (FR-098). What it
6
- * does is ask the app for one at open and hold it exactly as long as the
7
- * connection that needs it — which is also why no keychain library appears in
8
- * this package's dependencies (LIB-004).
9
- *
10
- * The provider receives the resolved scope, so an app can key per identity.
11
- * That is not a convenience: it is what makes destroying one identity's key a
12
- * meaningful answer to a deletion request, rather than a gesture that leaves
13
- * the data readable under a shared key (FR-104).
14
- */
15
-
16
- import type { EncryptionConfig } from '../contract/encryption';
17
- import { DiagnosticCodes } from '../contract/errors';
18
- import type { ResolvedScope } from '../contract/scope';
19
- import { raise } from '../diagnostics/errors';
20
- import { throwIfFaulted } from '../diagnostics/faults';
21
-
22
- /**
23
- * A key with an explicit end of life.
24
- *
25
- * Handing the string around directly would leave copies wherever it was
26
- * passed. A handle that can be released gives the connection one place to
27
- * drop it, and gives `key-hygiene.test.ts` something to assert against.
28
- */
29
- export type KeyHandle = {
30
- value(): string;
31
- release(): void;
32
- readonly released: boolean;
33
- };
34
-
35
- export async function acquireKey(
36
- encryption: EncryptionConfig,
37
- scope: ResolvedScope,
38
- ): Promise<KeyHandle> {
39
- let key: string | null;
40
-
41
- throwIfFaulted('keyProvider');
42
-
43
- try {
44
- key = await encryption.keyProvider.getKey(scope);
45
- } catch (error) {
46
- // A locked device is an expected condition, not a bug (FR-102), which
47
- // is why this code is retryable and distinct from an invalid key.
48
- raise(DiagnosticCodes.KEY_PROVIDER_FAILED, {
49
- message: 'key provider failed',
50
- cause: error,
51
- });
52
- }
53
-
54
- if (typeof key !== 'string' || key.length === 0) {
55
- raise(DiagnosticCodes.KEY_INVALID, {
56
- message: 'key provider returned no usable key',
57
- });
58
- }
59
-
60
- let released = false;
61
- return {
62
- value(): string {
63
- if (released || key === null) {
64
- raise(DiagnosticCodes.KEY_INVALID, {
65
- message: 'key material has already been released',
66
- });
67
- }
68
- return key;
69
- },
70
- release(): void {
71
- released = true;
72
- key = null;
73
- },
74
- get released(): boolean {
75
- return released;
76
- },
77
- };
78
- }
1
+ /**
2
+ * Key acquisition, scoped to one handle's lifetime.
3
+ *
4
+ * The package never generates, derives from its own material, persists,
5
+ * caches beyond the handle, transmits, or returns a key (FR-098). What it
6
+ * does is ask the app for one at open and hold it exactly as long as the
7
+ * connection that needs it — which is also why no keychain library appears in
8
+ * this package's dependencies (LIB-004).
9
+ *
10
+ * The provider receives the resolved scope, so an app can key per identity.
11
+ * That is not a convenience: it is what makes destroying one identity's key a
12
+ * meaningful answer to a deletion request, rather than a gesture that leaves
13
+ * the data readable under a shared key (FR-104).
14
+ */
15
+
16
+ import type { EncryptionConfig } from '../contract/encryption';
17
+ import { DiagnosticCodes } from '../contract/errors';
18
+ import type { ResolvedScope } from '../contract/scope';
19
+ import { raise } from '../diagnostics/errors';
20
+ import { throwIfFaulted } from '../diagnostics/faults';
21
+
22
+ /**
23
+ * A key with an explicit end of life.
24
+ *
25
+ * Handing the string around directly would leave copies wherever it was
26
+ * passed. A handle that can be released gives the connection one place to
27
+ * drop it, and gives `key-hygiene.test.ts` something to assert against.
28
+ */
29
+ export type KeyHandle = {
30
+ value(): string;
31
+ release(): void;
32
+ readonly released: boolean;
33
+ };
34
+
35
+ export async function acquireKey(
36
+ encryption: EncryptionConfig,
37
+ scope: ResolvedScope,
38
+ ): Promise<KeyHandle> {
39
+ let key: string | null;
40
+
41
+ throwIfFaulted('keyProvider');
42
+
43
+ try {
44
+ key = await encryption.keyProvider.getKey(scope);
45
+ } catch (error) {
46
+ // A locked device is an expected condition, not a bug (FR-102), which
47
+ // is why this code is retryable and distinct from an invalid key.
48
+ raise(DiagnosticCodes.KEY_PROVIDER_FAILED, {
49
+ message: 'key provider failed',
50
+ cause: error,
51
+ });
52
+ }
53
+
54
+ if (typeof key !== 'string' || key.length === 0) {
55
+ raise(DiagnosticCodes.KEY_INVALID, {
56
+ message: 'key provider returned no usable key',
57
+ });
58
+ }
59
+
60
+ let released = false;
61
+ return {
62
+ value(): string {
63
+ if (released || key === null) {
64
+ raise(DiagnosticCodes.KEY_INVALID, {
65
+ message: 'key material has already been released',
66
+ });
67
+ }
68
+ return key;
69
+ },
70
+ release(): void {
71
+ released = true;
72
+ key = null;
73
+ },
74
+ get released(): boolean {
75
+ return released;
76
+ },
77
+ };
78
+ }
@@ -1,81 +1,81 @@
1
- /**
2
- * The cipher peer, behind one lazy accessor.
3
- *
4
- * Marked optional in `peerDependenciesMeta`: an app that never enables
5
- * encryption should not have to install a native crypto module. That makes
6
- * "the peer is absent" a supported state rather than a broken install, so it
7
- * reports `ENCRYPTION_UNAVAILABLE` — the same code as a missing SQLCipher
8
- * build flag, because from the app's side it is the same problem: encryption
9
- * was asked for and cannot be delivered (research.md R1a).
10
- */
11
-
12
- import { DiagnosticCodes } from '../contract/errors';
13
- import { raise } from '../diagnostics/errors';
14
-
15
- export type CipherModule = {
16
- createCipheriv(
17
- algorithm: string,
18
- key: Uint8Array,
19
- iv: Uint8Array,
20
- ): {
21
- update(data: Uint8Array): Uint8Array;
22
- final(): Uint8Array;
23
- getAuthTag(): Uint8Array;
24
- };
25
- createDecipheriv(
26
- algorithm: string,
27
- key: Uint8Array,
28
- iv: Uint8Array,
29
- ): {
30
- update(data: Uint8Array): Uint8Array;
31
- final(): Uint8Array;
32
- setAuthTag(tag: Uint8Array): void;
33
- };
34
- hkdfSync(
35
- digest: string,
36
- key: Uint8Array,
37
- salt: Uint8Array,
38
- info: Uint8Array,
39
- length: number,
40
- ): ArrayBuffer;
41
- createHash(algorithm: string): {
42
- update(data: Uint8Array | string): { digest(encoding: string): string };
43
- };
44
- };
45
-
46
- let override: CipherModule | null = null;
47
- let cached: CipherModule | null = null;
48
-
49
- export function setCipherModule(module: CipherModule | null): void {
50
- override = module;
51
- cached = null;
52
- }
53
-
54
- 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
- }
65
-
66
- 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, {
75
- message: 'cipher peer is not installed',
76
- cause: error,
77
- });
78
- }
79
- }
80
- return cached!;
81
- }
1
+ /**
2
+ * The cipher peer, behind one lazy accessor.
3
+ *
4
+ * Marked optional in `peerDependenciesMeta`: an app that never enables
5
+ * encryption should not have to install a native crypto module. That makes
6
+ * "the peer is absent" a supported state rather than a broken install, so it
7
+ * reports `ENCRYPTION_UNAVAILABLE` — the same code as a missing SQLCipher
8
+ * build flag, because from the app's side it is the same problem: encryption
9
+ * was asked for and cannot be delivered (research.md R1a).
10
+ */
11
+
12
+ import { DiagnosticCodes } from '../contract/errors';
13
+ import { raise } from '../diagnostics/errors';
14
+
15
+ export type CipherModule = {
16
+ createCipheriv(
17
+ algorithm: string,
18
+ key: Uint8Array,
19
+ iv: Uint8Array,
20
+ ): {
21
+ update(data: Uint8Array): Uint8Array;
22
+ final(): Uint8Array;
23
+ getAuthTag(): Uint8Array;
24
+ };
25
+ createDecipheriv(
26
+ algorithm: string,
27
+ key: Uint8Array,
28
+ iv: Uint8Array,
29
+ ): {
30
+ update(data: Uint8Array): Uint8Array;
31
+ final(): Uint8Array;
32
+ setAuthTag(tag: Uint8Array): void;
33
+ };
34
+ hkdfSync(
35
+ digest: string,
36
+ key: Uint8Array,
37
+ salt: Uint8Array,
38
+ info: Uint8Array,
39
+ length: number,
40
+ ): ArrayBuffer;
41
+ createHash(algorithm: string): {
42
+ update(data: Uint8Array | string): { digest(encoding: string): string };
43
+ };
44
+ };
45
+
46
+ let override: CipherModule | null = null;
47
+ let cached: CipherModule | null = null;
48
+
49
+ export function setCipherModule(module: CipherModule | null): void {
50
+ override = module;
51
+ cached = null;
52
+ }
53
+
54
+ 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
+ }
65
+
66
+ 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, {
75
+ message: 'cipher peer is not installed',
76
+ cause: error,
77
+ });
78
+ }
79
+ }
80
+ return cached!;
81
+ }
@@ -1,108 +1,112 @@
1
- /**
2
- * Development-only diagnostics.
3
- *
4
- * Operation names, durations, and counts are safe to report. Record payloads,
5
- * field values, and query parameters are not — in any build, including this
6
- * one (FR-080, FR-081). There is deliberately no path here that accepts a
7
- * payload, so a future caller cannot pass one by accident.
8
- */
9
-
10
- export type DiagnosticEvent = {
11
- operation: string;
12
- durationMs?: number;
13
- count?: number;
14
- collection?: string;
15
- adapter?: 'sqlite' | 'memory';
16
- /** A diagnostic code when the operation failed. Never a message. */
17
- outcome?: string;
18
- };
19
-
20
- export type DiagnosticLogger = (event: DiagnosticEvent) => void;
21
-
22
- let logger: DiagnosticLogger | null = null;
23
-
24
- /** Passing null removes the logger. */
25
- export function setDiagnosticLogger(next: DiagnosticLogger | null): void {
26
- logger = next;
27
- }
28
-
29
- function isDevelopmentBuild(): boolean {
30
- // __DEV__ is a React Native global; the typeof guard keeps this usable
31
- // under plain Node when the package is exercised from Jest.
32
- return typeof __DEV__ !== 'undefined' ? __DEV__ === true : process.env.NODE_ENV !== 'production';
33
- }
34
-
35
- export function logDiagnostic(event: DiagnosticEvent): void {
36
- if (!logger || !isDevelopmentBuild()) {
37
- return;
38
- }
39
- logger(event);
40
- }
41
-
42
- export function isLogging(): boolean {
43
- return logger !== null && isDevelopmentBuild();
44
- }
45
-
46
- /**
47
- * Wrap a collection so every operation reports its name, duration, and result
48
- * count — and nothing else.
49
- *
50
- * Wrapping once here rather than instrumenting each operation in the adapters
51
- * is what keeps the guarantee checkable: there is one place that decides what
52
- * a log line may contain, and it has no access to a payload to leak even if a
53
- * future change wanted one. The wrapper is applied only when a logger is
54
- * installed and the build is a development build, so a production bundle pays
55
- * nothing and emits nothing (FR-081).
56
- */
57
- export function instrument<T extends Record<string, (...args: never[]) => unknown>>(
58
- collection: string,
59
- adapter: 'sqlite' | 'memory',
60
- operations: T,
61
- ): T {
62
- if (!isLogging()) {
63
- return operations;
64
- }
65
-
66
- const wrapped: Record<string, unknown> = {};
67
- for (const [name, operation] of Object.entries(operations)) {
68
- wrapped[name] = async (...args: never[]): Promise<unknown> => {
69
- const started = Date.now();
70
- try {
71
- const result = await (operation as (...inner: never[]) => Promise<unknown>)(...args);
72
- logDiagnostic({
73
- operation: `${collection}.${name}`,
74
- durationMs: Date.now() - started,
75
- collection,
76
- adapter,
77
- ...(countOf(result) === undefined ? {} : { count: countOf(result) }),
78
- });
79
- return result;
80
- } catch (error) {
81
- // The code, never the message: a failure log is exactly where a
82
- // payload-bearing native message would otherwise end up.
83
- logDiagnostic({
84
- operation: `${collection}.${name}`,
85
- durationMs: Date.now() - started,
86
- collection,
87
- adapter,
88
- outcome: (error as { code?: string }).code ?? 'failed',
89
- });
90
- throw error;
91
- }
92
- };
93
- }
94
- return wrapped as T;
95
- }
96
-
97
- /** How many records an operation produced, when that is a meaningful answer. */
98
- function countOf(result: unknown): number | undefined {
99
- if (typeof result === 'number') {
100
- return result;
101
- }
102
- if (result && typeof result === 'object' && Array.isArray((result as { records?: unknown }).records)) {
103
- return (result as { records: unknown[] }).records.length;
104
- }
105
- return undefined;
106
- }
107
-
108
- declare const __DEV__: boolean | undefined;
1
+ /**
2
+ * Development-only diagnostics.
3
+ *
4
+ * Operation names, durations, and counts are safe to report. Record payloads,
5
+ * field values, and query parameters are not — in any build, including this
6
+ * one (FR-080, FR-081). There is deliberately no path here that accepts a
7
+ * payload, so a future caller cannot pass one by accident.
8
+ */
9
+
10
+ export type DiagnosticEvent = {
11
+ operation: string;
12
+ durationMs?: number;
13
+ count?: number;
14
+ collection?: string;
15
+ adapter?: 'sqlite' | 'memory';
16
+ /** A diagnostic code when the operation failed. Never a message. */
17
+ outcome?: string;
18
+ };
19
+
20
+ export type DiagnosticLogger = (event: DiagnosticEvent) => void;
21
+
22
+ let logger: DiagnosticLogger | null = null;
23
+
24
+ /** Passing null removes the logger. */
25
+ export function setDiagnosticLogger(next: DiagnosticLogger | null): void {
26
+ logger = next;
27
+ }
28
+
29
+ function isDevelopmentBuild(): boolean {
30
+ // __DEV__ is a React Native global; the typeof guard keeps this usable
31
+ // under plain Node when the package is exercised from Jest.
32
+ return typeof __DEV__ !== 'undefined' ? __DEV__ === true : process.env.NODE_ENV !== 'production';
33
+ }
34
+
35
+ export function logDiagnostic(event: DiagnosticEvent): void {
36
+ if (!logger || !isDevelopmentBuild()) {
37
+ return;
38
+ }
39
+ logger(event);
40
+ }
41
+
42
+ export function isLogging(): boolean {
43
+ return logger !== null && isDevelopmentBuild();
44
+ }
45
+
46
+ /**
47
+ * Wrap a collection so every operation reports its name, duration, and result
48
+ * count — and nothing else.
49
+ *
50
+ * Wrapping once here rather than instrumenting each operation in the adapters
51
+ * is what keeps the guarantee checkable: there is one place that decides what
52
+ * a log line may contain, and it has no access to a payload to leak even if a
53
+ * future change wanted one. The wrapper is applied only when a logger is
54
+ * installed and the build is a development build, so a production bundle pays
55
+ * nothing and emits nothing (FR-081).
56
+ */
57
+ export function instrument<T extends Record<string, (...args: never[]) => unknown>>(
58
+ collection: string,
59
+ adapter: 'sqlite' | 'memory',
60
+ operations: T,
61
+ ): T {
62
+ if (!isLogging()) {
63
+ return operations;
64
+ }
65
+
66
+ const wrapped: Record<string, unknown> = {};
67
+ for (const [name, operation] of Object.entries(operations)) {
68
+ wrapped[name] = async (...args: never[]): Promise<unknown> => {
69
+ const started = Date.now();
70
+ try {
71
+ const result = await (operation as (...inner: never[]) => Promise<unknown>)(...args);
72
+ logDiagnostic({
73
+ operation: `${collection}.${name}`,
74
+ durationMs: Date.now() - started,
75
+ collection,
76
+ adapter,
77
+ ...(countOf(result) === undefined ? {} : { count: countOf(result) }),
78
+ });
79
+ return result;
80
+ } catch (error) {
81
+ // The code, never the message: a failure log is exactly where a
82
+ // payload-bearing native message would otherwise end up.
83
+ logDiagnostic({
84
+ operation: `${collection}.${name}`,
85
+ durationMs: Date.now() - started,
86
+ collection,
87
+ adapter,
88
+ outcome: (error as { code?: string }).code ?? 'failed',
89
+ });
90
+ throw error;
91
+ }
92
+ };
93
+ }
94
+ return wrapped as T;
95
+ }
96
+
97
+ /** How many records an operation produced, when that is a meaningful answer. */
98
+ function countOf(result: unknown): number | undefined {
99
+ if (typeof result === 'number') {
100
+ return result;
101
+ }
102
+ if (
103
+ result &&
104
+ typeof result === 'object' &&
105
+ Array.isArray((result as { records?: unknown }).records)
106
+ ) {
107
+ return (result as { records: unknown[] }).records.length;
108
+ }
109
+ return undefined;
110
+ }
111
+
112
+ declare const __DEV__: boolean | undefined;
@@ -1,66 +1,62 @@
1
- /**
2
- * The single error type the package raises.
3
- *
4
- * Everything is redacted on the way in (see redact.ts), so there is no path
5
- * by which a field value, query parameter, scope component, or key reaches an
6
- * error object at all.
7
- */
8
-
9
- import {
10
- DiagnosticCodes,
11
- RETRYABLE_CODES,
12
- type DiagnosticCode,
13
- } from '../contract/errors';
14
- import type { MigrationFailureDetail } from '../contract/migration';
15
- import { redactCause, redactDetail, sanitizeAuthoredMessage } from './redact';
16
-
17
- export type StorageErrorInit = {
18
- /**
19
- * A message this package authored. Never foreign text — a native or
20
- * consumer-thrown message goes in `cause`, which reduces it to an
21
- * identifier. `authored-messages.test.ts` checks every call site.
22
- */
23
- message?: string;
24
- detail?: Record<string, unknown>;
25
- migration?: MigrationFailureDetail;
26
- cause?: unknown;
27
- };
28
-
29
- export class StorageError extends Error {
30
- readonly code: DiagnosticCode;
31
- readonly retryable: boolean;
32
- readonly detail?: Record<string, string | number | boolean>;
33
- readonly migration?: MigrationFailureDetail;
34
- readonly redactedCause?: string;
35
-
36
- constructor(code: DiagnosticCode, init: StorageErrorInit = {}) {
37
- super(init.message ? sanitizeAuthoredMessage(init.message) : code);
38
- this.name = 'StorageError';
39
- this.code = code;
40
- this.retryable = RETRYABLE_CODES.has(code);
41
- this.detail = redactDetail(init.detail);
42
- // Version numbers and an outcome enum — no payload can hide in these.
43
- this.migration = init.migration;
44
- this.redactedCause = redactCause(init.cause);
45
- // `cause` is deliberately NOT assigned: the native error it came from
46
- // may quote the offending value, and assigning it would reintroduce
47
- // exactly the leak redaction exists to prevent.
48
- Object.setPrototypeOf(this, StorageError.prototype);
49
- }
50
- }
51
-
52
- export function isStorageError(value: unknown): value is StorageError {
53
- return value instanceof StorageError;
54
- }
55
-
56
- /** So no consumer ever has to parse a message string (FR-082). */
57
- export function isCode(value: unknown, code: DiagnosticCode): boolean {
58
- return isStorageError(value) && value.code === code;
59
- }
60
-
61
- export function raise(code: DiagnosticCode, init?: StorageErrorInit): never {
62
- throw new StorageError(code, init);
63
- }
64
-
65
- export { DiagnosticCodes };
66
- export type { DiagnosticCode };
1
+ /**
2
+ * The single error type the package raises.
3
+ *
4
+ * Everything is redacted on the way in (see redact.ts), so there is no path
5
+ * by which a field value, query parameter, scope component, or key reaches an
6
+ * error object at all.
7
+ */
8
+
9
+ import { DiagnosticCodes, RETRYABLE_CODES, type DiagnosticCode } from '../contract/errors';
10
+ import type { MigrationFailureDetail } from '../contract/migration';
11
+ import { redactCause, redactDetail, sanitizeAuthoredMessage } from './redact';
12
+
13
+ export type StorageErrorInit = {
14
+ /**
15
+ * A message this package authored. Never foreign text — a native or
16
+ * consumer-thrown message goes in `cause`, which reduces it to an
17
+ * identifier. `authored-messages.test.ts` checks every call site.
18
+ */
19
+ message?: string;
20
+ detail?: Record<string, unknown>;
21
+ migration?: MigrationFailureDetail;
22
+ cause?: unknown;
23
+ };
24
+
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;
31
+
32
+ constructor(code: DiagnosticCode, init: StorageErrorInit = {}) {
33
+ super(init.message ? sanitizeAuthoredMessage(init.message) : code);
34
+ this.name = 'StorageError';
35
+ this.code = code;
36
+ this.retryable = RETRYABLE_CODES.has(code);
37
+ this.detail = redactDetail(init.detail);
38
+ // Version numbers and an outcome enum — no payload can hide in these.
39
+ 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
+ }
47
+
48
+ export function isStorageError(value: unknown): value is StorageError {
49
+ return value instanceof StorageError;
50
+ }
51
+
52
+ /** So no consumer ever has to parse a message string (FR-082). */
53
+ export function isCode(value: unknown, code: DiagnosticCode): boolean {
54
+ return isStorageError(value) && value.code === code;
55
+ }
56
+
57
+ export function raise(code: DiagnosticCode, init?: StorageErrorInit): never {
58
+ throw new StorageError(code, init);
59
+ }
60
+
61
+ export { DiagnosticCodes };
62
+ export type { DiagnosticCode };