@chipmobilesdk/rn-local-db 0.1.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 (81) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/README.md +726 -0
  3. package/package.json +61 -0
  4. package/src/adapter/budgets.ts +67 -0
  5. package/src/adapter/collection.ts +298 -0
  6. package/src/adapter/deletion.ts +238 -0
  7. package/src/adapter/index.ts +68 -0
  8. package/src/adapter/lifecycle.ts +444 -0
  9. package/src/adapter/transaction.ts +65 -0
  10. package/src/artifacts/availability.ts +69 -0
  11. package/src/artifacts/cascade.ts +78 -0
  12. package/src/artifacts/paths.ts +29 -0
  13. package/src/artifacts/reconcile.ts +63 -0
  14. package/src/artifacts/store.ts +323 -0
  15. package/src/contract/adapter.ts +78 -0
  16. package/src/contract/attachment.ts +72 -0
  17. package/src/contract/backup.ts +28 -0
  18. package/src/contract/config.ts +54 -0
  19. package/src/contract/encryption.ts +34 -0
  20. package/src/contract/errors.ts +76 -0
  21. package/src/contract/migration.ts +40 -0
  22. package/src/contract/query.ts +81 -0
  23. package/src/contract/record.ts +42 -0
  24. package/src/contract/schema.ts +36 -0
  25. package/src/contract/scope.ts +36 -0
  26. package/src/contract/transaction.ts +46 -0
  27. package/src/crypto/chunked-cipher.ts +144 -0
  28. package/src/crypto/derive.ts +63 -0
  29. package/src/crypto/key-provider.ts +78 -0
  30. package/src/crypto/provider.ts +81 -0
  31. package/src/diagnostics/dev-log.ts +108 -0
  32. package/src/diagnostics/errors.ts +66 -0
  33. package/src/diagnostics/faults.ts +64 -0
  34. package/src/diagnostics/map-native.ts +106 -0
  35. package/src/diagnostics/redact.ts +141 -0
  36. package/src/index.ts +110 -0
  37. package/src/migration/executor.ts +136 -0
  38. package/src/migration/ledger.ts +97 -0
  39. package/src/migration/validate-chain.ts +65 -0
  40. package/src/platform/backup-android-verify.ts +88 -0
  41. package/src/platform/backup-ios.ts +48 -0
  42. package/src/platform/fs.ts +89 -0
  43. package/src/platform/location.ts +135 -0
  44. package/src/query/budgets.ts +57 -0
  45. package/src/query/compare.ts +91 -0
  46. package/src/query/cursor.ts +99 -0
  47. package/src/query/filter.ts +82 -0
  48. package/src/query/paginate.ts +51 -0
  49. package/src/query/sort.ts +56 -0
  50. package/src/query/validate.ts +171 -0
  51. package/src/schema/declare.ts +45 -0
  52. package/src/schema/validate-config.ts +158 -0
  53. package/src/scope/hash.ts +134 -0
  54. package/src/scope/normalize.ts +90 -0
  55. package/src/scope/validate.ts +83 -0
  56. package/src/sqlite/adapter.ts +26 -0
  57. package/src/sqlite/batch.ts +232 -0
  58. package/src/sqlite/catalog.ts +108 -0
  59. package/src/sqlite/connection.ts +205 -0
  60. package/src/sqlite/index-coverage.ts +53 -0
  61. package/src/sqlite/native.ts +83 -0
  62. package/src/sqlite/row-codec.ts +201 -0
  63. package/src/sqlite/sql-builder.ts +244 -0
  64. package/src/sqlite/transactions.ts +33 -0
  65. package/src/sqlite/values.ts +24 -0
  66. package/src/testing/conformance/attachments.ts +136 -0
  67. package/src/testing/conformance/encryption.ts +150 -0
  68. package/src/testing/conformance/index.ts +161 -0
  69. package/src/testing/conformance/lifecycle.ts +99 -0
  70. package/src/testing/conformance/migrations.ts +177 -0
  71. package/src/testing/conformance/queries.ts +167 -0
  72. package/src/testing/conformance/records.ts +169 -0
  73. package/src/testing/conformance/scopes.ts +88 -0
  74. package/src/testing/conformance/transactions.ts +149 -0
  75. package/src/testing/faults.ts +21 -0
  76. package/src/testing/index.ts +21 -0
  77. package/src/testing/memory-adapter/collection.ts +219 -0
  78. package/src/testing/memory-adapter/index.ts +388 -0
  79. package/src/testing/memory-adapter/migrations.ts +119 -0
  80. package/src/testing/memory-adapter/reset.ts +50 -0
  81. package/src/testing/memory-adapter/store.ts +134 -0
@@ -0,0 +1,149 @@
1
+ import { DiagnosticCodes } from '../../contract/errors';
2
+ import { isCode } from '../../diagnostics/errors';
3
+ import type { ConformanceContext } from './index';
4
+
5
+ /** US4: all-or-nothing writes. */
6
+ export function registerTransactionConformance(context: ConformanceContext): void {
7
+ describe('conformance: transactions', () => {
8
+ beforeEach(() => context.reset());
9
+
10
+ const scope = { kind: 'guest' } as const;
11
+
12
+ it('commits every write together', async () => {
13
+ const handle = await context.adapter.open(context.configFor(scope));
14
+ await handle.transaction(async tx => {
15
+ await tx.collection('notes').insert({ id: 'a', data: { title: 'a' } });
16
+ await tx.collection('plain').insert({ id: 'b', data: { label: 'b' } });
17
+ });
18
+
19
+ expect(await handle.collection('notes').find('a')).not.toBeNull();
20
+ expect(await handle.collection('plain').find('b')).not.toBeNull();
21
+ await handle.close();
22
+ });
23
+
24
+ it('rolls back every write when one fails', async () => {
25
+ const handle = await context.adapter.open(context.configFor(scope));
26
+ await handle.collection('notes').insert({ id: 'existing', data: { title: 'kept' } });
27
+
28
+ let thrown: unknown;
29
+ try {
30
+ await handle.transaction(async tx => {
31
+ await tx.collection('notes').insert({ id: 'new', data: { title: 'new' } });
32
+ // Conflicts with `existing`, so the whole transaction unwinds.
33
+ await tx.collection('notes').insert({ id: 'existing', data: { title: 'clash' } });
34
+ });
35
+ } catch (error) {
36
+ thrown = error;
37
+ }
38
+
39
+ expect(isCode(thrown, DiagnosticCodes.RECORD_CONFLICT)).toBe(true);
40
+ expect(await handle.collection('notes').find('new')).toBeNull();
41
+ expect((await handle.collection('notes').get('existing')).data.title).toBe('kept');
42
+ await handle.close();
43
+ });
44
+
45
+ it.each([0, 1, 2, 3])('rolls back cleanly when step %p fails', async failAt => {
46
+ const handle = await context.adapter.open(context.configFor(scope));
47
+ const before = await handle.collection('notes').count();
48
+
49
+ try {
50
+ await handle.transaction(async tx => {
51
+ for (let step = 0; step < 4; step += 1) {
52
+ if (step === failAt) {
53
+ throw new Error('injected');
54
+ }
55
+ await tx.collection('notes').insert({ id: `s${step}`, data: { score: step } });
56
+ }
57
+ });
58
+ } catch {
59
+ // Expected.
60
+ }
61
+
62
+ expect(await handle.collection('notes').count()).toBe(before);
63
+ await handle.close();
64
+ });
65
+
66
+ it('discards everything on an explicit abort', async () => {
67
+ const handle = await context.adapter.open(context.configFor(scope));
68
+ await handle.transaction(async tx => {
69
+ await tx.collection('notes').insert({ id: 'a', data: { title: 'a' } });
70
+ await tx.abort();
71
+ });
72
+ expect(await handle.collection('notes').find('a')).toBeNull();
73
+ await handle.close();
74
+ });
75
+
76
+ it('sees its own uncommitted writes inside the transaction', async () => {
77
+ const handle = await context.adapter.open(context.configFor(scope));
78
+ await handle.transaction(async tx => {
79
+ const notes = tx.collection('notes');
80
+ await notes.insert({ id: 'a', data: { title: 'inside' } });
81
+ expect((await notes.get('a')).data.title).toBe('inside');
82
+ });
83
+ await handle.close();
84
+ });
85
+
86
+ it('rejects use after the transaction settles', async () => {
87
+ const handle = await context.adapter.open(context.configFor(scope));
88
+ let thrown: unknown;
89
+ await handle.transaction(async tx => {
90
+ await tx.abort();
91
+ try {
92
+ tx.collection('notes');
93
+ } catch (error) {
94
+ thrown = error;
95
+ }
96
+ });
97
+ expect(isCode(thrown, DiagnosticCodes.TRANSACTION_STATE)).toBe(true);
98
+ await handle.close();
99
+ });
100
+
101
+ it('applies a batch as one unit', async () => {
102
+ const handle = await context.adapter.open(context.configFor(scope));
103
+ const outcome = await handle.batch([
104
+ { type: 'insert', collection: 'notes', record: { id: 'a', data: { score: 1 } } },
105
+ { type: 'insert', collection: 'notes', record: { id: 'b', data: { score: 2 } } },
106
+ { type: 'upsert', collection: 'plain', record: { id: 'c', data: { label: 'c' } } },
107
+ ]);
108
+ expect(outcome.applied).toBe(3);
109
+ expect(await handle.collection('notes').count()).toBe(2);
110
+ await handle.close();
111
+ });
112
+
113
+ it('rolls back the whole batch when one operation fails', async () => {
114
+ const handle = await context.adapter.open(context.configFor(scope));
115
+ await handle.collection('notes').insert({ id: 'a', data: { score: 0 } });
116
+
117
+ try {
118
+ await handle.batch([
119
+ { type: 'insert', collection: 'notes', record: { id: 'b', data: { score: 1 } } },
120
+ { type: 'insert', collection: 'notes', record: { id: 'a', data: { score: 9 } } },
121
+ ]);
122
+ } catch {
123
+ // Expected.
124
+ }
125
+
126
+ expect(await handle.collection('notes').find('b')).toBeNull();
127
+ expect((await handle.collection('notes').get('a')).data.score).toBe(0);
128
+ await handle.close();
129
+ });
130
+
131
+ it('rejects a batch over the operation budget', async () => {
132
+ const handle = await context.adapter.open(context.configFor(scope));
133
+ const operations = Array.from({ length: 1001 }, (_, i) => ({
134
+ type: 'upsert' as const,
135
+ collection: 'notes',
136
+ record: { id: `x${i}`, data: { score: i } },
137
+ }));
138
+
139
+ let thrown: unknown;
140
+ try {
141
+ await handle.batch(operations);
142
+ } catch (error) {
143
+ thrown = error;
144
+ }
145
+ expect(isCode(thrown, DiagnosticCodes.BATCH_TOO_LARGE)).toBe(true);
146
+ await handle.close();
147
+ });
148
+ });
149
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Fault injection, exposed only through the `./testing` entrypoint.
3
+ *
4
+ * Produces the same typed errors the on-device implementation produces, so a
5
+ * consumer can test its recovery paths without a device that is actually out
6
+ * of space or actually corrupt (FR-095). The registry itself lives in
7
+ * `diagnostics/faults.ts`, because the *checks* have to run in production code
8
+ * for the demo app's panel to exercise the real adapter — only the ability to
9
+ * arm one is kept behind this entrypoint.
10
+ */
11
+
12
+ export {
13
+ activeFaults,
14
+ clearFaults,
15
+ injectFault,
16
+ throwIfFaulted,
17
+ type FaultPoint,
18
+ type FaultSpec,
19
+ } from '../diagnostics/faults';
20
+
21
+ export { DiagnosticCodes } from '../contract/errors';
@@ -0,0 +1,21 @@
1
+ /**
2
+ * `@chipmobilesdk/rn-local-db/testing`
3
+ *
4
+ * Test-only surface, kept behind its own entrypoint so the in-memory engine
5
+ * never reaches a consuming app's production bundle (research.md R10).
6
+ */
7
+
8
+ export { createMemoryAdapter, type MemoryAdapterOptions } from './memory-adapter';
9
+ export {
10
+ memoryConformanceHooks,
11
+ resetMemoryAdapter,
12
+ seedRecords,
13
+ } from './memory-adapter/reset';
14
+ export { clearFaults, injectFault, type FaultPoint, type FaultSpec } from './faults';
15
+ export {
16
+ runConformanceSuite,
17
+ type ConformanceContext,
18
+ type ConformanceHooks,
19
+ type ConformanceOptions,
20
+ type ConformanceSection,
21
+ } from './conformance';
@@ -0,0 +1,219 @@
1
+ /**
2
+ * Collection operations over the in-memory store.
3
+ *
4
+ * Every semantic decision — filtering, sorting, paging — is delegated to
5
+ * `query/`, the module the SQLite adapter also validates through. Anything
6
+ * decided here instead would be a place the two adapters could drift.
7
+ */
8
+
9
+ import { DiagnosticCodes } from '../../contract/errors';
10
+ import type { FilterNode, Query, ResultPage } from '../../contract/query';
11
+ import type {
12
+ RecordInput,
13
+ StorableRecordData,
14
+ StoredRecord,
15
+ WriteOutcome,
16
+ } from '../../contract/record';
17
+ import type { CollectionSchema } from '../../contract/schema';
18
+ import type { CollectionOps } from '../../contract/transaction';
19
+ import { raise } from '../../diagnostics/errors';
20
+ import { throwIfFaulted } from '../../diagnostics/faults';
21
+ import { assertRecordWithinBudget } from '../../query/budgets';
22
+ import { evaluateFilter } from '../../query/filter';
23
+ import { applyCursor, buildPage, resolvePageSize } from '../../query/paginate';
24
+ import { sortRecords, withStableTiebreaker } from '../../query/sort';
25
+ import { validateQuery } from '../../query/validate';
26
+ import { hasSoftDelete, hasTimestamps } from '../../schema/declare';
27
+ import {
28
+ collectionMap,
29
+ encodePayload,
30
+ toStoredRecord,
31
+ type MemoryScopeStore,
32
+ } from './store';
33
+
34
+ export type CollectionContext = {
35
+ store: MemoryScopeStore;
36
+ schema: CollectionSchema;
37
+ now: () => number;
38
+ assertOpen: () => void;
39
+ };
40
+
41
+ function notFound(collection: string, id: string): never {
42
+ raise(DiagnosticCodes.RECORD_NOT_FOUND, {
43
+ message: 'record not found',
44
+ detail: { collection, recordId: id },
45
+ });
46
+ }
47
+
48
+ export function createMemoryCollection<T extends StorableRecordData>(
49
+ context: CollectionContext,
50
+ ): CollectionOps<T> {
51
+ const { schema } = context;
52
+ const rows = () => collectionMap(context.store, schema.name);
53
+
54
+ const readable = (includeSoftDeleted: boolean): StoredRecord<T>[] => {
55
+ const out: StoredRecord<T>[] = [];
56
+ for (const row of rows().values()) {
57
+ if (!includeSoftDeleted && row.deletedAt != null) {
58
+ continue;
59
+ }
60
+ out.push(toStoredRecord<T>(row));
61
+ }
62
+ return out;
63
+ };
64
+
65
+ return {
66
+ async insert(record: RecordInput<T>): Promise<WriteOutcome> {
67
+ context.assertOpen();
68
+ throwIfFaulted('write');
69
+ assertRecordWithinBudget(schema.name, record.data);
70
+ if (rows().has(record.id)) {
71
+ // Overwriting silently is how a caller loses data without ever
72
+ // seeing a failure.
73
+ raise(DiagnosticCodes.RECORD_CONFLICT, {
74
+ message: 'record already exists',
75
+ detail: { collection: schema.name, recordId: record.id },
76
+ });
77
+ }
78
+ const now = context.now();
79
+ rows().set(record.id, {
80
+ id: record.id,
81
+ payload: encodePayload(record.data),
82
+ createdAt: hasTimestamps(schema) ? now : undefined,
83
+ updatedAt: hasTimestamps(schema) ? now : undefined,
84
+ deletedAt: hasSoftDelete(schema) ? null : undefined,
85
+ });
86
+ return { operation: 'created', id: record.id };
87
+ },
88
+
89
+ async update(id, record): Promise<WriteOutcome> {
90
+ context.assertOpen();
91
+ throwIfFaulted('write');
92
+ assertRecordWithinBudget(schema.name, record.data);
93
+ const existing = rows().get(id);
94
+ if (!existing || existing.deletedAt != null) {
95
+ notFound(schema.name, id);
96
+ }
97
+ existing.payload = encodePayload(record.data);
98
+ if (hasTimestamps(schema)) {
99
+ existing.updatedAt = context.now();
100
+ }
101
+ return { operation: 'updated', id };
102
+ },
103
+
104
+ async upsert(record: RecordInput<T>): Promise<WriteOutcome> {
105
+ context.assertOpen();
106
+ throwIfFaulted('write');
107
+ assertRecordWithinBudget(schema.name, record.data);
108
+ const existing = rows().get(record.id);
109
+ const now = context.now();
110
+ if (existing) {
111
+ existing.payload = encodePayload(record.data);
112
+ if (hasTimestamps(schema)) {
113
+ existing.updatedAt = now;
114
+ }
115
+ existing.deletedAt = hasSoftDelete(schema) ? null : undefined;
116
+ return { operation: 'updated', id: record.id };
117
+ }
118
+ rows().set(record.id, {
119
+ id: record.id,
120
+ payload: encodePayload(record.data),
121
+ createdAt: hasTimestamps(schema) ? now : undefined,
122
+ updatedAt: hasTimestamps(schema) ? now : undefined,
123
+ deletedAt: hasSoftDelete(schema) ? null : undefined,
124
+ });
125
+ return { operation: 'created', id: record.id };
126
+ },
127
+
128
+ async get(id: string): Promise<StoredRecord<T>> {
129
+ context.assertOpen();
130
+ throwIfFaulted('read');
131
+ const row = rows().get(id);
132
+ if (!row || row.deletedAt != null) {
133
+ notFound(schema.name, id);
134
+ }
135
+ return toStoredRecord<T>(row);
136
+ },
137
+
138
+ async find(id: string): Promise<StoredRecord<T> | null> {
139
+ context.assertOpen();
140
+ throwIfFaulted('read');
141
+ const row = rows().get(id);
142
+ if (!row || row.deletedAt != null) {
143
+ return null;
144
+ }
145
+ return toStoredRecord<T>(row);
146
+ },
147
+
148
+ async delete(id: string): Promise<WriteOutcome> {
149
+ context.assertOpen();
150
+ throwIfFaulted('write');
151
+ if (!rows().delete(id)) {
152
+ notFound(schema.name, id);
153
+ }
154
+ return { operation: 'deleted', id };
155
+ },
156
+
157
+ async softDelete(id: string): Promise<WriteOutcome> {
158
+ context.assertOpen();
159
+ throwIfFaulted('write');
160
+ if (!hasSoftDelete(schema)) {
161
+ raise(DiagnosticCodes.CONFIG_INVALID, {
162
+ message: 'collection does not declare soft delete',
163
+ detail: { collection: schema.name },
164
+ });
165
+ }
166
+ const row = rows().get(id);
167
+ if (!row || row.deletedAt != null) {
168
+ notFound(schema.name, id);
169
+ }
170
+ row.deletedAt = context.now();
171
+ return { operation: 'deleted', id };
172
+ },
173
+
174
+ async restore(id: string): Promise<WriteOutcome> {
175
+ context.assertOpen();
176
+ throwIfFaulted('write');
177
+ if (!hasSoftDelete(schema)) {
178
+ raise(DiagnosticCodes.CONFIG_INVALID, {
179
+ message: 'collection does not declare soft delete',
180
+ detail: { collection: schema.name },
181
+ });
182
+ }
183
+ const row = rows().get(id);
184
+ if (!row || row.deletedAt == null) {
185
+ notFound(schema.name, id);
186
+ }
187
+ row.deletedAt = null;
188
+ return { operation: 'restored', id };
189
+ },
190
+
191
+ async list(query?: Query): Promise<ResultPage<T>> {
192
+ context.assertOpen();
193
+ throwIfFaulted('read');
194
+ validateQuery(schema, query);
195
+ const sort = withStableTiebreaker(query?.sort);
196
+ const size = resolvePageSize(query?.page);
197
+
198
+ let matched = readable(query?.includeSoftDeleted === true);
199
+ if (query?.filter) {
200
+ matched = matched.filter(record => evaluateFilter(record.data, query.filter!));
201
+ }
202
+ const ordered = applyCursor(sortRecords(matched, sort), sort, query?.page?.cursor);
203
+ return buildPage(ordered.slice(0, size + 1), sort, size);
204
+ },
205
+
206
+ async count(filter?: FilterNode): Promise<number> {
207
+ context.assertOpen();
208
+ throwIfFaulted('read');
209
+ if (filter) {
210
+ validateQuery(schema, { filter });
211
+ }
212
+ const matched = readable(false);
213
+ if (!filter) {
214
+ return matched.length;
215
+ }
216
+ return matched.filter(record => evaluateFilter(record.data, filter)).length;
217
+ },
218
+ };
219
+ }