@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.
Files changed (84) hide show
  1. package/CHANGELOG.md +191 -102
  2. package/README.md +728 -728
  3. package/package.json +62 -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 +75 -81
  32. package/src/diagnostics/dev-log.ts +132 -108
  33. package/src/diagnostics/errors.ts +63 -66
  34. package/src/diagnostics/faults.ts +58 -64
  35. package/src/diagnostics/map-native.ts +108 -106
  36. package/src/diagnostics/redact.ts +119 -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 +49 -48
  43. package/src/platform/bytes.ts +157 -144
  44. package/src/platform/fs.ts +90 -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 +84 -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 +139 -136
  70. package/src/testing/conformance/encryption.ts +151 -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 +511 -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,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 { 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 { 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
- 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
+ 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;