@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.
- package/CHANGELOG.md +122 -102
- package/README.md +728 -728
- 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 -252
- package/src/adapter/index.ts +64 -68
- package/src/adapter/lifecycle.ts +517 -520
- package/src/adapter/transaction.ts +71 -71
- package/src/artifacts/availability.ts +67 -67
- package/src/artifacts/cascade.ts +114 -114
- package/src/artifacts/paths.ts +29 -29
- package/src/artifacts/reconcile.ts +63 -63
- package/src/artifacts/store.ts +334 -345
- package/src/contract/adapter.ts +76 -78
- package/src/contract/attachment.ts +75 -78
- 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 -42
- package/src/contract/query.ts +81 -81
- package/src/contract/record.ts +46 -51
- package/src/contract/schema.ts +36 -36
- package/src/contract/scope.ts +36 -36
- package/src/contract/transaction.ts +52 -54
- package/src/crypto/chunked-cipher.ts +153 -150
- package/src/crypto/derive.ts +64 -64
- 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 -144
- package/src/platform/fs.ts +89 -89
- package/src/platform/location.ts +135 -135
- package/src/query/budgets.ts +54 -54
- package/src/query/compare.ts +91 -91
- package/src/query/cursor.ts +100 -100
- 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 -242
- package/src/sqlite/catalog.ts +105 -108
- package/src/sqlite/connection.ts +227 -227
- package/src/sqlite/index-coverage.ts +54 -56
- package/src/sqlite/native.ts +83 -83
- package/src/sqlite/row-codec.ts +217 -221
- package/src/sqlite/sql-builder.ts +244 -244
- package/src/sqlite/transaction-queue.ts +87 -87
- 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 -492
- 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,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
|
+
}
|
package/src/crypto/provider.ts
CHANGED
|
@@ -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 (
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
readonly
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
this.
|
|
40
|
-
this.
|
|
41
|
-
|
|
42
|
-
//
|
|
43
|
-
|
|
44
|
-
this
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
export function
|
|
58
|
-
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
export
|
|
62
|
-
|
|
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 };
|