@chipmobilesdk/rn-local-db 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +191 -102
- package/README.md +728 -728
- package/package.json +62 -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 +75 -81
- package/src/diagnostics/dev-log.ts +132 -108
- package/src/diagnostics/errors.ts +63 -66
- package/src/diagnostics/faults.ts +58 -64
- package/src/diagnostics/map-native.ts +108 -106
- package/src/diagnostics/redact.ts +119 -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 +49 -48
- package/src/platform/bytes.ts +157 -144
- package/src/platform/fs.ts +90 -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 +84 -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 +139 -136
- package/src/testing/conformance/encryption.ts +151 -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 +511 -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,75 @@
|
|
|
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 {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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 { lazyPeer } from '@chipmobilesdk/rn-core';
|
|
13
|
+
|
|
14
|
+
import { DiagnosticCodes } from '../contract/errors';
|
|
15
|
+
import { StorageError } from '../diagnostics/errors';
|
|
16
|
+
|
|
17
|
+
export type CipherModule = {
|
|
18
|
+
createCipheriv(
|
|
19
|
+
algorithm: string,
|
|
20
|
+
key: Uint8Array,
|
|
21
|
+
iv: Uint8Array,
|
|
22
|
+
): {
|
|
23
|
+
update(data: Uint8Array): Uint8Array;
|
|
24
|
+
final(): Uint8Array;
|
|
25
|
+
getAuthTag(): Uint8Array;
|
|
26
|
+
};
|
|
27
|
+
createDecipheriv(
|
|
28
|
+
algorithm: string,
|
|
29
|
+
key: Uint8Array,
|
|
30
|
+
iv: Uint8Array,
|
|
31
|
+
): {
|
|
32
|
+
update(data: Uint8Array): Uint8Array;
|
|
33
|
+
final(): Uint8Array;
|
|
34
|
+
setAuthTag(tag: Uint8Array): void;
|
|
35
|
+
};
|
|
36
|
+
hkdfSync(
|
|
37
|
+
digest: string,
|
|
38
|
+
key: Uint8Array,
|
|
39
|
+
salt: Uint8Array,
|
|
40
|
+
info: Uint8Array,
|
|
41
|
+
length: number,
|
|
42
|
+
): ArrayBuffer;
|
|
43
|
+
createHash(algorithm: string): {
|
|
44
|
+
update(data: Uint8Array | string): { digest(encoding: string): string };
|
|
45
|
+
};
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/** Loaded on first use (rn-core's lazy peer); optional, so absence is a normal answer. */
|
|
49
|
+
const cipher = lazyPeer('react-native-quick-crypto', () => {
|
|
50
|
+
// Inside `try`, so Metro bundles an app that leaves this optional peer out.
|
|
51
|
+
try {
|
|
52
|
+
return require('react-native-quick-crypto') as CipherModule;
|
|
53
|
+
} catch (cause) {
|
|
54
|
+
throw cause;
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
/** Swap the module. Tests only; `null` goes back to the real one. */
|
|
59
|
+
export function setCipherModule(module: CipherModule | null): void {
|
|
60
|
+
cipher.override(module ?? undefined);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function isCipherAvailable(): boolean {
|
|
64
|
+
return cipher.isAvailable();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function cipherModule(): CipherModule {
|
|
68
|
+
return cipher.require(
|
|
69
|
+
(_peer, cause) =>
|
|
70
|
+
new StorageError(DiagnosticCodes.ENCRYPTION_UNAVAILABLE, {
|
|
71
|
+
message: 'cipher peer is not installed',
|
|
72
|
+
cause,
|
|
73
|
+
}),
|
|
74
|
+
);
|
|
75
|
+
}
|
|
@@ -1,108 +1,132 @@
|
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
):
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
+
import { createDiagnosticChannel, hasSdkSinks } from '@chipmobilesdk/rn-core';
|
|
11
|
+
|
|
12
|
+
export type DiagnosticEvent = {
|
|
13
|
+
operation: string;
|
|
14
|
+
durationMs?: number;
|
|
15
|
+
count?: number | undefined;
|
|
16
|
+
collection?: string;
|
|
17
|
+
adapter?: 'sqlite' | 'memory';
|
|
18
|
+
/** A diagnostic code when the operation failed. Never a message. */
|
|
19
|
+
outcome?: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type DiagnosticLogger = (event: DiagnosticEvent) => void;
|
|
23
|
+
|
|
24
|
+
let logger: DiagnosticLogger | null = null;
|
|
25
|
+
|
|
26
|
+
/** Passing null removes the logger. */
|
|
27
|
+
export function setDiagnosticLogger(next: DiagnosticLogger | null): void {
|
|
28
|
+
logger = next;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function isDevelopmentBuild(): boolean {
|
|
32
|
+
// __DEV__ is a React Native global; the typeof guard keeps this usable
|
|
33
|
+
// under plain Node when the package is exercised from Jest.
|
|
34
|
+
if (typeof __DEV__ !== 'undefined') return __DEV__ === true;
|
|
35
|
+
const { process } = globalThis as { process?: { env?: Record<string, string | undefined> } };
|
|
36
|
+
return process?.env?.NODE_ENV !== 'production';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* A failed operation also reaches the app's SDK sinks (`installSdkSink` from
|
|
41
|
+
* rn-core, CORE-08), in any build: its code, the operation and the collection,
|
|
42
|
+
* which are identifiers this package defines. Successes stay with the
|
|
43
|
+
* development logger.
|
|
44
|
+
*/
|
|
45
|
+
const channel = createDiagnosticChannel('rn-local-db');
|
|
46
|
+
|
|
47
|
+
export function logDiagnostic(event: DiagnosticEvent): void {
|
|
48
|
+
if (event.outcome !== undefined) {
|
|
49
|
+
channel.emit(event.outcome, 'warn', {
|
|
50
|
+
operation: event.operation,
|
|
51
|
+
...(event.collection === undefined ? {} : { collection: event.collection }),
|
|
52
|
+
...(event.adapter === undefined ? {} : { adapter: event.adapter }),
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
if (!logger || !isDevelopmentBuild()) {
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
logger(event);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function isLogging(): boolean {
|
|
62
|
+
return logger !== null && isDevelopmentBuild();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Wrap a collection so every operation reports its name, duration, and result
|
|
67
|
+
* count — and nothing else.
|
|
68
|
+
*
|
|
69
|
+
* Wrapping once here rather than instrumenting each operation in the adapters
|
|
70
|
+
* is what keeps the guarantee checkable: there is one place that decides what
|
|
71
|
+
* a log line may contain, and it has no access to a payload to leak even if a
|
|
72
|
+
* future change wanted one. The wrapper is applied only when a logger is
|
|
73
|
+
* installed and the build is a development build, so a production bundle pays
|
|
74
|
+
* nothing and emits nothing (FR-081).
|
|
75
|
+
*/
|
|
76
|
+
export function instrument<T extends Record<string, (...args: never[]) => unknown>>(
|
|
77
|
+
collection: string,
|
|
78
|
+
adapter: 'sqlite' | 'memory',
|
|
79
|
+
operations: T,
|
|
80
|
+
): T {
|
|
81
|
+
// Unwrapped unless someone is listening: production pays nothing by default.
|
|
82
|
+
if (!isLogging() && !hasSdkSinks()) {
|
|
83
|
+
return operations;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const wrapped: Record<string, unknown> = {};
|
|
87
|
+
for (const [name, operation] of Object.entries(operations)) {
|
|
88
|
+
wrapped[name] = async (...args: never[]): Promise<unknown> => {
|
|
89
|
+
const started = Date.now();
|
|
90
|
+
try {
|
|
91
|
+
const result = await (operation as (...inner: never[]) => Promise<unknown>)(...args);
|
|
92
|
+
logDiagnostic({
|
|
93
|
+
operation: `${collection}.${name}`,
|
|
94
|
+
durationMs: Date.now() - started,
|
|
95
|
+
collection,
|
|
96
|
+
adapter,
|
|
97
|
+
...(countOf(result) === undefined ? {} : { count: countOf(result) }),
|
|
98
|
+
});
|
|
99
|
+
return result;
|
|
100
|
+
} catch (error) {
|
|
101
|
+
// The code, never the message: a failure log is exactly where a
|
|
102
|
+
// payload-bearing native message would otherwise end up.
|
|
103
|
+
logDiagnostic({
|
|
104
|
+
operation: `${collection}.${name}`,
|
|
105
|
+
durationMs: Date.now() - started,
|
|
106
|
+
collection,
|
|
107
|
+
adapter,
|
|
108
|
+
outcome: (error as { code?: string }).code ?? 'failed',
|
|
109
|
+
});
|
|
110
|
+
throw error;
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
return wrapped as T;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** How many records an operation produced, when that is a meaningful answer. */
|
|
118
|
+
function countOf(result: unknown): number | undefined {
|
|
119
|
+
if (typeof result === 'number') {
|
|
120
|
+
return result;
|
|
121
|
+
}
|
|
122
|
+
if (
|
|
123
|
+
result &&
|
|
124
|
+
typeof result === 'object' &&
|
|
125
|
+
Array.isArray((result as { records?: unknown }).records)
|
|
126
|
+
) {
|
|
127
|
+
return (result as { records: unknown[] }).records.length;
|
|
128
|
+
}
|
|
129
|
+
return undefined;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
declare const __DEV__: boolean | undefined;
|