@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,66 +1,63 @@
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 { SdkError } from '@chipmobilesdk/rn-core';
10
+
11
+ import { DiagnosticCodes, RETRYABLE_CODES, type DiagnosticCode } from '../contract/errors';
12
+ import type { MigrationFailureDetail } from '../contract/migration';
13
+ import { redactCause, redactDetail, sanitizeAuthoredMessage } from './redact';
14
+
15
+ export type StorageErrorInit = {
16
+ /**
17
+ * A message this package authored. Never foreign text — a native or
18
+ * consumer-thrown message goes in `cause`, which reduces it to an
19
+ * identifier. `authored-messages.test.ts` checks every call site.
20
+ */
21
+ message?: string;
22
+ detail?: Record<string, unknown> | undefined;
23
+ migration?: MigrationFailureDetail | undefined;
24
+ cause?: unknown;
25
+ };
26
+
27
+ /** The package's error; an `SdkError` (`@chipmobilesdk/rn-core`) with `pkg: 'rn-local-db'`. */
28
+ export class StorageError extends SdkError<DiagnosticCode> {
29
+ readonly detail?: Record<string, string | number | boolean> | undefined;
30
+ readonly migration?: MigrationFailureDetail | undefined;
31
+
32
+ constructor(code: DiagnosticCode, init: StorageErrorInit = {}) {
33
+ // Call sites pass only authored literals (authored-messages.test.ts).
34
+ const message = init.message ? sanitizeAuthoredMessage(init.message) : code;
35
+ // `cause` is deliberately NOT kept: the native error it came from may
36
+ // quote the offending value. Only its redacted identifier survives.
37
+ super('rn-local-db', code, {
38
+ message,
39
+ retryable: RETRYABLE_CODES.has(code),
40
+ redactedCause: redactCause(init.cause),
41
+ });
42
+ this.name = 'StorageError';
43
+ this.detail = redactDetail(init.detail);
44
+ // Version numbers and an outcome enum — no payload can hide in these.
45
+ this.migration = init.migration;
46
+ }
47
+ }
48
+
49
+ export function isStorageError(value: unknown): value is StorageError {
50
+ return value instanceof StorageError;
51
+ }
52
+
53
+ /** So no consumer ever has to parse a message string (FR-082). */
54
+ export function isCode(value: unknown, code: DiagnosticCode): boolean {
55
+ return isStorageError(value) && value.code === code;
56
+ }
57
+
58
+ export function raise(code: DiagnosticCode, init?: StorageErrorInit): never {
59
+ throw new StorageError(code, init);
60
+ }
61
+
62
+ export { DiagnosticCodes };
63
+ export type { DiagnosticCode };
@@ -1,64 +1,58 @@
1
- /**
2
- * The fault registry.
3
- *
4
- * It lives here rather than under `testing/` because FR-095 wants every
5
- * documented failure reachable from a real app running the real adapter — the
6
- * demo's fault-injection panel exercises the on-device path, not a simulation
7
- * of it. So the check has to exist in production code.
8
- *
9
- * What does *not* exist in production code is a way to inject: `injectFault`
10
- * and `clearFaults` are exported only from the `./testing` entrypoint. With
11
- * nothing injected this is one lookup in an empty map, which is the price of
12
- * being able to prove a recovery path works before a user finds it broken.
13
- */
14
-
15
- import type { DiagnosticCode } from '../contract/errors';
16
- import { StorageError } from './errors';
17
-
18
- export type FaultPoint =
19
- | 'open'
20
- | 'read'
21
- | 'write'
22
- | 'commit'
23
- | 'migration'
24
- | 'attachmentWrite'
25
- | 'keyProvider';
26
-
27
- export type FaultSpec = {
28
- at: FaultPoint;
29
- code: DiagnosticCode;
30
- /** Fire this many times, then stop. Omit for "until cleared". */
31
- times?: number;
32
- };
33
-
34
- const faults = new Map<FaultPoint, { code: DiagnosticCode; remaining: number }>();
35
-
36
- export function injectFault(spec: FaultSpec): void {
37
- faults.set(spec.at, {
38
- code: spec.code,
39
- remaining: spec.times ?? Number.POSITIVE_INFINITY,
40
- });
41
- }
42
-
43
- export function clearFaults(): void {
44
- faults.clear();
45
- }
46
-
47
- export function activeFaults(): FaultPoint[] {
48
- return [...faults.keys()];
49
- }
50
-
51
- export function throwIfFaulted(point: FaultPoint): void {
52
- if (faults.size === 0) {
53
- return;
54
- }
55
- const fault = faults.get(point);
56
- if (!fault || fault.remaining <= 0) {
57
- return;
58
- }
59
- fault.remaining -= 1;
60
- if (fault.remaining <= 0) {
61
- faults.delete(point);
62
- }
63
- throw new StorageError(fault.code, { message: `injected fault at ${point}` });
64
- }
1
+ /**
2
+ * The fault registry.
3
+ *
4
+ * It lives here rather than under `testing/` because FR-095 wants every
5
+ * documented failure reachable from a real app running the real adapter — the
6
+ * demo's fault-injection panel exercises the on-device path, not a simulation
7
+ * of it. So the check has to exist in production code.
8
+ *
9
+ * What does *not* exist in production code is a way to inject: `injectFault`
10
+ * and `clearFaults` are exported only from the `./testing` entrypoint. With
11
+ * nothing injected this is one lookup in an empty map, which is the price of
12
+ * being able to prove a recovery path works before a user finds it broken.
13
+ */
14
+
15
+ import type { DiagnosticCode } from '../contract/errors';
16
+ import { StorageError } from './errors';
17
+
18
+ export type FaultPoint =
19
+ 'open' | 'read' | 'write' | 'commit' | 'migration' | 'attachmentWrite' | 'keyProvider';
20
+
21
+ export type FaultSpec = {
22
+ at: FaultPoint;
23
+ code: DiagnosticCode;
24
+ /** Fire this many times, then stop. Omit for "until cleared". */
25
+ times?: number;
26
+ };
27
+
28
+ const faults = new Map<FaultPoint, { code: DiagnosticCode; remaining: number }>();
29
+
30
+ export function injectFault(spec: FaultSpec): void {
31
+ faults.set(spec.at, {
32
+ code: spec.code,
33
+ remaining: spec.times ?? Number.POSITIVE_INFINITY,
34
+ });
35
+ }
36
+
37
+ export function clearFaults(): void {
38
+ faults.clear();
39
+ }
40
+
41
+ export function activeFaults(): FaultPoint[] {
42
+ return [...faults.keys()];
43
+ }
44
+
45
+ export function throwIfFaulted(point: FaultPoint): void {
46
+ if (faults.size === 0) {
47
+ return;
48
+ }
49
+ const fault = faults.get(point);
50
+ if (!fault || fault.remaining <= 0) {
51
+ return;
52
+ }
53
+ fault.remaining -= 1;
54
+ if (fault.remaining <= 0) {
55
+ faults.delete(point);
56
+ }
57
+ throw new StorageError(fault.code, { message: `injected fault at ${point}` });
58
+ }
@@ -1,106 +1,108 @@
1
- /**
2
- * Native error to diagnostic code.
3
- *
4
- * The three failures FR-076 insists stay apart — a full disk, a filesystem
5
- * the app cannot reach, and a corrupt database — arrive from SQLite and from
6
- * the filesystem peer as ordinary errors with a string on them. Collapsing
7
- * them into one write failure would be the easy thing and the wrong one: an
8
- * app frees space for the first, retries after unlock for the second, and has
9
- * to offer a reset for the third.
10
- *
11
- * Only the code is derived here. The message never travels — it is redacted
12
- * at construction by `StorageError` (FR-079).
13
- */
14
-
15
- import { DiagnosticCodes, type DiagnosticCode } from '../contract/errors';
16
- import { isStorageError, raise } from './errors';
17
-
18
- export type NativeContext = {
19
- /** Where the failure happened, so ambiguous signatures resolve correctly. */
20
- operation: 'open' | 'read' | 'write' | 'transaction' | 'attachment' | 'delete';
21
- /**
22
- * Whether the database was opened with a key. "File is not a database" is
23
- * SQLCipher's answer to a wrong key and SQLite's answer to a corrupt file;
24
- * the only thing that separates them is whether a key was in play.
25
- */
26
- encrypted?: boolean;
27
- };
28
-
29
- function textOf(error: unknown): string {
30
- if (error instanceof Error) {
31
- return `${error.message} ${(error as { code?: string }).code ?? ''}`;
32
- }
33
- if (typeof error === 'string') {
34
- return error;
35
- }
36
- return '';
37
- }
38
-
39
- const DISK_FULL = /SQLITE_FULL|database or disk is full|ENOSPC|no space left/i;
40
- const NOT_A_DATABASE = /SQLITE_NOTADB|file is not a database|encrypted or is not a database/i;
41
- const CORRUPT = /SQLITE_CORRUPT|database disk image is malformed|malformed database/i;
42
- const CONFLICT = /UNIQUE constraint failed|PRIMARY KEY must be unique|SQLITE_CONSTRAINT_PRIMARYKEY/i;
43
- const ACCESS = /SQLITE_CANTOPEN|SQLITE_READONLY|SQLITE_PERM|SQLITE_AUTH|EACCES|EPERM|EROFS|ENOENT|EISDIR/i;
44
- const BUSY = /SQLITE_BUSY|SQLITE_LOCKED|database is locked/i;
45
-
46
- export function mapNativeError(error: unknown, context: NativeContext): DiagnosticCode {
47
- const text = textOf(error);
48
-
49
- if (DISK_FULL.test(text)) {
50
- return DiagnosticCodes.DISK_FULL;
51
- }
52
- if (NOT_A_DATABASE.test(text)) {
53
- // A wrong key and a corrupt file are indistinguishable from the engine's
54
- // answer alone. Reporting corruption for a wrong key would push an app
55
- // towards destroying recoverable data, so the encrypted case wins.
56
- return context.encrypted ? DiagnosticCodes.KEY_INVALID : DiagnosticCodes.CORRUPTION;
57
- }
58
- if (CORRUPT.test(text)) {
59
- return DiagnosticCodes.CORRUPTION;
60
- }
61
- if (CONFLICT.test(text)) {
62
- return DiagnosticCodes.RECORD_CONFLICT;
63
- }
64
- if (ACCESS.test(text)) {
65
- return DiagnosticCodes.STORAGE_ACCESS;
66
- }
67
- if (BUSY.test(text)) {
68
- // Retryable, and distinct from a permanent write failure.
69
- return context.operation === 'transaction'
70
- ? DiagnosticCodes.TRANSACTION_FAILED
71
- : DiagnosticCodes.WRITE_FAILED;
72
- }
73
-
74
- switch (context.operation) {
75
- case 'open':
76
- return DiagnosticCodes.OPEN_FAILED;
77
- case 'read':
78
- return DiagnosticCodes.STORAGE_ACCESS;
79
- case 'transaction':
80
- return DiagnosticCodes.TRANSACTION_FAILED;
81
- case 'attachment':
82
- return DiagnosticCodes.ATTACHMENT_WRITE_FAILED;
83
- case 'delete':
84
- case 'write':
85
- default:
86
- return DiagnosticCodes.WRITE_FAILED;
87
- }
88
- }
89
-
90
- /**
91
- * Re-raise a native failure as a typed one, preserving an error the package
92
- * already classified rather than reclassifying it as a generic write failure.
93
- */
94
- export function raiseNative(error: unknown, context: NativeContext): never {
95
- if (isStorageError(error)) {
96
- throw error;
97
- }
98
- raise(mapNativeError(error, context), {
99
- // The native message is deliberately not the error's message. It goes
100
- // through `cause`, which redacts it — passing it here as well would be
101
- // two chances to leak the same string for no extra information.
102
- message: `native ${context.operation} failed`,
103
- detail: { operation: context.operation },
104
- cause: error,
105
- });
106
- }
1
+ /**
2
+ * Native error to diagnostic code.
3
+ *
4
+ * The three failures FR-076 insists stay apart — a full disk, a filesystem
5
+ * the app cannot reach, and a corrupt database — arrive from SQLite and from
6
+ * the filesystem peer as ordinary errors with a string on them. Collapsing
7
+ * them into one write failure would be the easy thing and the wrong one: an
8
+ * app frees space for the first, retries after unlock for the second, and has
9
+ * to offer a reset for the third.
10
+ *
11
+ * Only the code is derived here. The message never travels — it is redacted
12
+ * at construction by `StorageError` (FR-079).
13
+ */
14
+
15
+ import { DiagnosticCodes, type DiagnosticCode } from '../contract/errors';
16
+ import { isStorageError, raise } from './errors';
17
+
18
+ export type NativeContext = {
19
+ /** Where the failure happened, so ambiguous signatures resolve correctly. */
20
+ operation: 'open' | 'read' | 'write' | 'transaction' | 'attachment' | 'delete';
21
+ /**
22
+ * Whether the database was opened with a key. "File is not a database" is
23
+ * SQLCipher's answer to a wrong key and SQLite's answer to a corrupt file;
24
+ * the only thing that separates them is whether a key was in play.
25
+ */
26
+ encrypted?: boolean;
27
+ };
28
+
29
+ function textOf(error: unknown): string {
30
+ if (error instanceof Error) {
31
+ return `${error.message} ${(error as { code?: string }).code ?? ''}`;
32
+ }
33
+ if (typeof error === 'string') {
34
+ return error;
35
+ }
36
+ return '';
37
+ }
38
+
39
+ const DISK_FULL = /SQLITE_FULL|database or disk is full|ENOSPC|no space left/i;
40
+ const NOT_A_DATABASE = /SQLITE_NOTADB|file is not a database|encrypted or is not a database/i;
41
+ const CORRUPT = /SQLITE_CORRUPT|database disk image is malformed|malformed database/i;
42
+ const CONFLICT =
43
+ /UNIQUE constraint failed|PRIMARY KEY must be unique|SQLITE_CONSTRAINT_PRIMARYKEY/i;
44
+ const ACCESS =
45
+ /SQLITE_CANTOPEN|SQLITE_READONLY|SQLITE_PERM|SQLITE_AUTH|EACCES|EPERM|EROFS|ENOENT|EISDIR/i;
46
+ const BUSY = /SQLITE_BUSY|SQLITE_LOCKED|database is locked/i;
47
+
48
+ export function mapNativeError(error: unknown, context: NativeContext): DiagnosticCode {
49
+ const text = textOf(error);
50
+
51
+ if (DISK_FULL.test(text)) {
52
+ return DiagnosticCodes.DISK_FULL;
53
+ }
54
+ if (NOT_A_DATABASE.test(text)) {
55
+ // A wrong key and a corrupt file are indistinguishable from the engine's
56
+ // answer alone. Reporting corruption for a wrong key would push an app
57
+ // towards destroying recoverable data, so the encrypted case wins.
58
+ return context.encrypted ? DiagnosticCodes.KEY_INVALID : DiagnosticCodes.CORRUPTION;
59
+ }
60
+ if (CORRUPT.test(text)) {
61
+ return DiagnosticCodes.CORRUPTION;
62
+ }
63
+ if (CONFLICT.test(text)) {
64
+ return DiagnosticCodes.RECORD_CONFLICT;
65
+ }
66
+ if (ACCESS.test(text)) {
67
+ return DiagnosticCodes.STORAGE_ACCESS;
68
+ }
69
+ if (BUSY.test(text)) {
70
+ // Retryable, and distinct from a permanent write failure.
71
+ return context.operation === 'transaction'
72
+ ? DiagnosticCodes.TRANSACTION_FAILED
73
+ : DiagnosticCodes.WRITE_FAILED;
74
+ }
75
+
76
+ switch (context.operation) {
77
+ case 'open':
78
+ return DiagnosticCodes.OPEN_FAILED;
79
+ case 'read':
80
+ return DiagnosticCodes.STORAGE_ACCESS;
81
+ case 'transaction':
82
+ return DiagnosticCodes.TRANSACTION_FAILED;
83
+ case 'attachment':
84
+ return DiagnosticCodes.ATTACHMENT_WRITE_FAILED;
85
+ case 'delete':
86
+ case 'write':
87
+ default:
88
+ return DiagnosticCodes.WRITE_FAILED;
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Re-raise a native failure as a typed one, preserving an error the package
94
+ * already classified rather than reclassifying it as a generic write failure.
95
+ */
96
+ export function raiseNative(error: unknown, context: NativeContext): never {
97
+ if (isStorageError(error)) {
98
+ throw error;
99
+ }
100
+ raise(mapNativeError(error, context), {
101
+ // The native message is deliberately not the error's message. It goes
102
+ // through `cause`, which redacts it — passing it here as well would be
103
+ // two chances to leak the same string for no extra information.
104
+ message: `native ${context.operation} failed`,
105
+ detail: { operation: context.operation },
106
+ cause: error,
107
+ });
108
+ }