@chipmobilesdk/rn-local-db 0.1.2 → 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.
Files changed (84) hide show
  1. package/CHANGELOG.md +122 -52
  2. package/README.md +663 -661
  3. package/package.json +59 -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 -238
  8. package/src/adapter/index.ts +64 -68
  9. package/src/adapter/lifecycle.ts +517 -444
  10. package/src/adapter/transaction.ts +71 -65
  11. package/src/artifacts/availability.ts +67 -69
  12. package/src/artifacts/cascade.ts +114 -78
  13. package/src/artifacts/paths.ts +29 -29
  14. package/src/artifacts/reconcile.ts +63 -63
  15. package/src/artifacts/store.ts +334 -323
  16. package/src/contract/adapter.ts +76 -78
  17. package/src/contract/attachment.ts +75 -72
  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 -40
  23. package/src/contract/query.ts +81 -81
  24. package/src/contract/record.ts +46 -42
  25. package/src/contract/schema.ts +36 -36
  26. package/src/contract/scope.ts +36 -36
  27. package/src/contract/transaction.ts +52 -46
  28. package/src/crypto/chunked-cipher.ts +153 -144
  29. package/src/crypto/derive.ts +64 -63
  30. package/src/crypto/key-provider.ts +78 -78
  31. package/src/crypto/provider.ts +81 -81
  32. package/src/diagnostics/dev-log.ts +112 -108
  33. package/src/diagnostics/errors.ts +62 -66
  34. package/src/diagnostics/faults.ts +58 -64
  35. package/src/diagnostics/map-native.ts +108 -106
  36. package/src/diagnostics/redact.ts +137 -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 +48 -48
  43. package/src/platform/bytes.ts +144 -0
  44. package/src/platform/fs.ts +89 -89
  45. package/src/platform/location.ts +135 -135
  46. package/src/query/budgets.ts +54 -57
  47. package/src/query/compare.ts +91 -91
  48. package/src/query/cursor.ts +100 -99
  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 -232
  60. package/src/sqlite/catalog.ts +105 -108
  61. package/src/sqlite/connection.ts +227 -205
  62. package/src/sqlite/index-coverage.ts +54 -53
  63. package/src/sqlite/native.ts +83 -83
  64. package/src/sqlite/row-codec.ts +217 -201
  65. package/src/sqlite/sql-builder.ts +244 -244
  66. package/src/sqlite/transaction-queue.ts +87 -0
  67. package/src/sqlite/transactions.ts +33 -33
  68. package/src/sqlite/values.ts +24 -24
  69. package/src/testing/conformance/attachments.ts +138 -136
  70. package/src/testing/conformance/encryption.ts +150 -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 +510 -388
  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,232 +1,242 @@
1
- /**
2
- * Batch writes: many operations, one atomic unit, one outcome (FR-028).
3
- *
4
- * Every statement is resolved before any of them runs. That ordering is the
5
- * point: a conflict or a missing record is reported with the database still
6
- * untouched, so "the batch failed" and "the batch was partly applied" are
7
- * never the same event. The statements then go to the engine's own
8
- * `executeBatch`, which wraps them in a single transaction — the batch is
9
- * atomic because SQLite makes it atomic, not because this file is careful.
10
- *
11
- * Resolution keeps an overlay of what earlier operations in the same batch
12
- * did, so `insert` followed by `update` on the same id resolves against the
13
- * batch's own effect rather than against the database as it was before it.
14
- */
15
-
16
- import { DiagnosticCodes } from '../contract/errors';
17
- import type { WriteOutcome } from '../contract/record';
18
- import type { CollectionSchema } from '../contract/schema';
19
- import type { BatchOperation, BatchOutcome } from '../contract/transaction';
20
- import { raise } from '../diagnostics/errors';
21
- import { assertBatchWithinBudget, assertRecordWithinBudget } from '../query/budgets';
22
- import { collectionTable, hasSoftDelete } from '../schema/declare';
23
- import type { Connection } from './connection';
24
- import type { SqlValue } from './native';
25
- import { insertColumns, updateAssignments } from './row-codec';
26
-
27
- export type BatchContext = {
28
- connection: Connection;
29
- /** Raises CONFIG_INVALID for a collection the schema never declared. */
30
- schemaFor(name: string): CollectionSchema;
31
- now(): number;
32
- };
33
-
34
- type Command = [string, SqlValue[]];
35
-
36
- function quoted(schema: CollectionSchema): string {
37
- return `"${collectionTable(schema.name)}"`;
38
- }
39
-
40
- function insertCommand(
41
- schema: CollectionSchema,
42
- id: string,
43
- data: Parameters<typeof insertColumns>[2],
44
- now: number,
45
- ): Command {
46
- const { columns, values } = insertColumns(schema, id, data, now);
47
- return [
48
- `INSERT INTO ${quoted(schema)} (${columns
49
- .map(name => `"${name}"`)
50
- .join(', ')}) VALUES (${columns.map(() => '?').join(', ')})`,
51
- values,
52
- ];
53
- }
54
-
55
- function updateCommand(
56
- schema: CollectionSchema,
57
- id: string,
58
- data: Parameters<typeof updateAssignments>[1],
59
- now: number,
60
- restore: boolean,
61
- ): Command {
62
- const { columns, values } = updateAssignments(schema, data, now);
63
- const assignments = columns.map(name => `"${name}" = ?`);
64
- if (restore && hasSoftDelete(schema)) {
65
- assignments.push('deleted_at = NULL');
66
- }
67
- return [`UPDATE ${quoted(schema)} SET ${assignments.join(', ')} WHERE id = ?`, [...values, id]];
68
- }
69
-
70
- type Resolver = {
71
- schema: CollectionSchema;
72
- now: number;
73
- /** Existence as this batch has left it so far, not as the database was. */
74
- exists(id: string): Promise<boolean>;
75
- stage(id: string, present: boolean): void;
76
- };
77
-
78
- type Resolved = { command: Command; outcome: WriteOutcome };
79
-
80
- function conflict(schema: CollectionSchema, id: string): never {
81
- raise(DiagnosticCodes.RECORD_CONFLICT, {
82
- message: 'record already exists',
83
- detail: { collection: schema.name, recordId: id },
84
- });
85
- }
86
-
87
- function missing(schema: CollectionSchema, id: string): never {
88
- raise(DiagnosticCodes.RECORD_NOT_FOUND, {
89
- message: 'record not found',
90
- detail: { collection: schema.name, recordId: id },
91
- });
92
- }
93
-
94
- async function resolveInsert(
95
- resolver: Resolver,
96
- operation: Extract<BatchOperation, { type: 'insert' }>,
97
- ): Promise<Resolved> {
98
- const { schema, now } = resolver;
99
- assertRecordWithinBudget(schema.name, operation.record.data);
100
- if (await resolver.exists(operation.record.id)) {
101
- conflict(schema, operation.record.id);
102
- }
103
- resolver.stage(operation.record.id, true);
104
- return {
105
- command: insertCommand(schema, operation.record.id, operation.record.data, now),
106
- outcome: { operation: 'created', id: operation.record.id },
107
- };
108
- }
109
-
110
- async function resolveUpdate(
111
- resolver: Resolver,
112
- operation: Extract<BatchOperation, { type: 'update' }>,
113
- ): Promise<Resolved> {
114
- const { schema, now } = resolver;
115
- assertRecordWithinBudget(schema.name, operation.data);
116
- if (!(await resolver.exists(operation.id))) {
117
- missing(schema, operation.id);
118
- }
119
- return {
120
- command: updateCommand(schema, operation.id, operation.data, now, false),
121
- outcome: { operation: 'updated', id: operation.id },
122
- };
123
- }
124
-
125
- async function resolveUpsert(
126
- resolver: Resolver,
127
- operation: Extract<BatchOperation, { type: 'upsert' }>,
128
- ): Promise<Resolved> {
129
- const { schema, now } = resolver;
130
- assertRecordWithinBudget(schema.name, operation.record.data);
131
- const present = await resolver.exists(operation.record.id);
132
- resolver.stage(operation.record.id, true);
133
- return {
134
- command: present
135
- ? updateCommand(schema, operation.record.id, operation.record.data, now, true)
136
- : insertCommand(schema, operation.record.id, operation.record.data, now),
137
- outcome: { operation: present ? 'updated' : 'created', id: operation.record.id },
138
- };
139
- }
140
-
141
- async function resolveDelete(
142
- resolver: Resolver,
143
- operation: Extract<BatchOperation, { type: 'delete' }>,
144
- ): Promise<Resolved> {
145
- const { schema } = resolver;
146
- if (!(await resolver.exists(operation.id))) {
147
- missing(schema, operation.id);
148
- }
149
- resolver.stage(operation.id, false);
150
- return {
151
- command: [`DELETE FROM ${quoted(schema)} WHERE id = ?`, [operation.id]],
152
- outcome: { operation: 'deleted', id: operation.id },
153
- };
154
- }
155
-
156
- async function resolveSoftDelete(
157
- resolver: Resolver,
158
- operation: Extract<BatchOperation, { type: 'softDelete' }>,
159
- ): Promise<Resolved> {
160
- const { schema, now } = resolver;
161
- if (!hasSoftDelete(schema)) {
162
- raise(DiagnosticCodes.CONFIG_INVALID, {
163
- message: 'collection does not declare soft delete',
164
- detail: { collection: schema.name },
165
- });
166
- }
167
- if (!(await resolver.exists(operation.id))) {
168
- missing(schema, operation.id);
169
- }
170
- resolver.stage(operation.id, false);
171
- return {
172
- command: [`UPDATE ${quoted(schema)} SET deleted_at = ? WHERE id = ?`, [now, operation.id]],
173
- outcome: { operation: 'deleted', id: operation.id },
174
- };
175
- }
176
-
177
- function resolveOperation(resolver: Resolver, operation: BatchOperation): Promise<Resolved> {
178
- switch (operation.type) {
179
- case 'insert':
180
- return resolveInsert(resolver, operation);
181
- case 'update':
182
- return resolveUpdate(resolver, operation);
183
- case 'upsert':
184
- return resolveUpsert(resolver, operation);
185
- case 'delete':
186
- return resolveDelete(resolver, operation);
187
- case 'softDelete':
188
- return resolveSoftDelete(resolver, operation);
189
- default:
190
- raise(DiagnosticCodes.CONFIG_INVALID, { message: 'unsupported batch operation' });
191
- }
192
- }
193
-
194
- export async function runBatch(
195
- context: BatchContext,
196
- operations: BatchOperation[],
197
- ): Promise<BatchOutcome> {
198
- assertBatchWithinBudget(operations.length);
199
-
200
- const commands: Command[] = [];
201
- const outcomes: WriteOutcome[] = [];
202
- const overlay = new Map<string, boolean>();
203
-
204
- for (const operation of operations) {
205
- const schema = context.schemaFor(operation.collection);
206
- const resolver: Resolver = {
207
- schema,
208
- now: context.now(),
209
- async exists(id) {
210
- const staged = overlay.get(`${schema.name}:${id}`);
211
- if (staged !== undefined) {
212
- return staged;
213
- }
214
- const result = await context.connection.execute(
215
- `SELECT 1 AS present FROM ${quoted(schema)} WHERE id = ? AND deleted_at IS NULL`,
216
- [id],
217
- );
218
- return result.rows.length > 0;
219
- },
220
- stage(id, present) {
221
- overlay.set(`${schema.name}:${id}`, present);
222
- },
223
- };
224
-
225
- const resolved = await resolveOperation(resolver, operation);
226
- commands.push(resolved.command);
227
- outcomes.push(resolved.outcome);
228
- }
229
-
230
- await context.connection.executeBatch(commands);
231
- return { applied: outcomes.length, outcomes };
232
- }
1
+ /**
2
+ * Batch writes: many operations, one atomic unit, one outcome (FR-028).
3
+ *
4
+ * Every statement is resolved before any of them runs. That ordering is the
5
+ * point: a conflict or a missing record is reported with the database still
6
+ * untouched, so "the batch failed" and "the batch was partly applied" are
7
+ * never the same event. The statements then go to the engine's own
8
+ * `executeBatch`, which wraps them in a single transaction — the batch is
9
+ * atomic because SQLite makes it atomic, not because this file is careful.
10
+ *
11
+ * Resolution keeps an overlay of what earlier operations in the same batch
12
+ * did, so `insert` followed by `update` on the same id resolves against the
13
+ * batch's own effect rather than against the database as it was before it.
14
+ */
15
+
16
+ import { DiagnosticCodes } from '../contract/errors';
17
+ import type { WriteOutcome } from '../contract/record';
18
+ import type { CollectionSchema } from '../contract/schema';
19
+ import type { BatchOperation, BatchOutcome } from '../contract/transaction';
20
+ import { raise } from '../diagnostics/errors';
21
+ import { assertBatchWithinBudget, assertRecordWithinBudget } from '../query/budgets';
22
+ import { collectionTable, hasSoftDelete } from '../schema/declare';
23
+ import type { Connection } from './connection';
24
+ import type { SqlValue } from './native';
25
+ import { insertColumns, updateAssignments } from './row-codec';
26
+
27
+ export type BatchContext = {
28
+ connection: Connection;
29
+ /** Raises CONFIG_INVALID for a collection the schema never declared. */
30
+ schemaFor(name: string): CollectionSchema;
31
+ now(): number;
32
+ };
33
+
34
+ type Command = [string, SqlValue[]];
35
+
36
+ function quoted(schema: CollectionSchema): string {
37
+ return `"${collectionTable(schema.name)}"`;
38
+ }
39
+
40
+ function insertCommand(
41
+ schema: CollectionSchema,
42
+ id: string,
43
+ data: Parameters<typeof insertColumns>[2],
44
+ now: number,
45
+ ): Command {
46
+ const { columns, values } = insertColumns(schema, id, data, now);
47
+ return [
48
+ `INSERT INTO ${quoted(schema)} (${columns
49
+ .map(name => `"${name}"`)
50
+ .join(', ')}) VALUES (${columns.map(() => '?').join(', ')})`,
51
+ values,
52
+ ];
53
+ }
54
+
55
+ function updateCommand(
56
+ schema: CollectionSchema,
57
+ id: string,
58
+ data: Parameters<typeof updateAssignments>[1],
59
+ now: number,
60
+ restore: boolean,
61
+ ): Command {
62
+ const { columns, values } = updateAssignments(schema, data, now);
63
+ const assignments = columns.map(name => `"${name}" = ?`);
64
+ if (restore && hasSoftDelete(schema)) {
65
+ assignments.push('deleted_at = NULL');
66
+ }
67
+ return [`UPDATE ${quoted(schema)} SET ${assignments.join(', ')} WHERE id = ?`, [...values, id]];
68
+ }
69
+
70
+ /**
71
+ * Where a record stands, as this batch has left it so far. Three states, not
72
+ * two: a soft-deleted row is invisible to reads but still holds its primary
73
+ * key, so an insert over it conflicts and an upsert over it restores it — the
74
+ * same rules the collection API applies. Treating it as absent made a batch
75
+ * upsert issue an INSERT and fail the whole batch on the primary key.
76
+ */
77
+ type RowState = 'live' | 'softDeleted' | 'absent';
78
+
79
+ type Resolver = {
80
+ schema: CollectionSchema;
81
+ now: number;
82
+ state(id: string): Promise<RowState>;
83
+ stage(id: string, state: RowState): void;
84
+ };
85
+
86
+ type Resolved = { command: Command; outcome: WriteOutcome };
87
+
88
+ function conflict(schema: CollectionSchema, id: string): never {
89
+ raise(DiagnosticCodes.RECORD_CONFLICT, {
90
+ message: 'record already exists',
91
+ detail: { collection: schema.name, recordId: id },
92
+ });
93
+ }
94
+
95
+ function missing(schema: CollectionSchema, id: string): never {
96
+ raise(DiagnosticCodes.RECORD_NOT_FOUND, {
97
+ message: 'record not found',
98
+ detail: { collection: schema.name, recordId: id },
99
+ });
100
+ }
101
+
102
+ async function resolveInsert(
103
+ resolver: Resolver,
104
+ operation: Extract<BatchOperation, { type: 'insert' }>,
105
+ ): Promise<Resolved> {
106
+ const { schema, now } = resolver;
107
+ assertRecordWithinBudget(schema.name, operation.record.data);
108
+ if ((await resolver.state(operation.record.id)) !== 'absent') {
109
+ conflict(schema, operation.record.id);
110
+ }
111
+ resolver.stage(operation.record.id, 'live');
112
+ return {
113
+ command: insertCommand(schema, operation.record.id, operation.record.data, now),
114
+ outcome: { operation: 'created', id: operation.record.id },
115
+ };
116
+ }
117
+
118
+ async function resolveUpdate(
119
+ resolver: Resolver,
120
+ operation: Extract<BatchOperation, { type: 'update' }>,
121
+ ): Promise<Resolved> {
122
+ const { schema, now } = resolver;
123
+ assertRecordWithinBudget(schema.name, operation.data);
124
+ if ((await resolver.state(operation.id)) !== 'live') {
125
+ missing(schema, operation.id);
126
+ }
127
+ return {
128
+ command: updateCommand(schema, operation.id, operation.data, now, false),
129
+ outcome: { operation: 'updated', id: operation.id },
130
+ };
131
+ }
132
+
133
+ async function resolveUpsert(
134
+ resolver: Resolver,
135
+ operation: Extract<BatchOperation, { type: 'upsert' }>,
136
+ ): Promise<Resolved> {
137
+ const { schema, now } = resolver;
138
+ assertRecordWithinBudget(schema.name, operation.record.data);
139
+ const present = (await resolver.state(operation.record.id)) !== 'absent';
140
+ resolver.stage(operation.record.id, 'live');
141
+ return {
142
+ command: present
143
+ ? updateCommand(schema, operation.record.id, operation.record.data, now, true)
144
+ : insertCommand(schema, operation.record.id, operation.record.data, now),
145
+ outcome: { operation: present ? 'updated' : 'created', id: operation.record.id },
146
+ };
147
+ }
148
+
149
+ async function resolveDelete(
150
+ resolver: Resolver,
151
+ operation: Extract<BatchOperation, { type: 'delete' }>,
152
+ ): Promise<Resolved> {
153
+ const { schema } = resolver;
154
+ if ((await resolver.state(operation.id)) !== 'live') {
155
+ missing(schema, operation.id);
156
+ }
157
+ resolver.stage(operation.id, 'absent');
158
+ return {
159
+ command: [`DELETE FROM ${quoted(schema)} WHERE id = ?`, [operation.id]],
160
+ outcome: { operation: 'deleted', id: operation.id },
161
+ };
162
+ }
163
+
164
+ async function resolveSoftDelete(
165
+ resolver: Resolver,
166
+ operation: Extract<BatchOperation, { type: 'softDelete' }>,
167
+ ): Promise<Resolved> {
168
+ const { schema, now } = resolver;
169
+ if (!hasSoftDelete(schema)) {
170
+ raise(DiagnosticCodes.CONFIG_INVALID, {
171
+ message: 'collection does not declare soft delete',
172
+ detail: { collection: schema.name },
173
+ });
174
+ }
175
+ if ((await resolver.state(operation.id)) !== 'live') {
176
+ missing(schema, operation.id);
177
+ }
178
+ resolver.stage(operation.id, 'softDeleted');
179
+ return {
180
+ command: [`UPDATE ${quoted(schema)} SET deleted_at = ? WHERE id = ?`, [now, operation.id]],
181
+ outcome: { operation: 'deleted', id: operation.id },
182
+ };
183
+ }
184
+
185
+ function resolveOperation(resolver: Resolver, operation: BatchOperation): Promise<Resolved> {
186
+ switch (operation.type) {
187
+ case 'insert':
188
+ return resolveInsert(resolver, operation);
189
+ case 'update':
190
+ return resolveUpdate(resolver, operation);
191
+ case 'upsert':
192
+ return resolveUpsert(resolver, operation);
193
+ case 'delete':
194
+ return resolveDelete(resolver, operation);
195
+ case 'softDelete':
196
+ return resolveSoftDelete(resolver, operation);
197
+ default:
198
+ raise(DiagnosticCodes.CONFIG_INVALID, { message: 'unsupported batch operation' });
199
+ }
200
+ }
201
+
202
+ export async function runBatch(
203
+ context: BatchContext,
204
+ operations: BatchOperation[],
205
+ ): Promise<BatchOutcome> {
206
+ assertBatchWithinBudget(operations.length);
207
+
208
+ const commands: Command[] = [];
209
+ const outcomes: WriteOutcome[] = [];
210
+ const overlay = new Map<string, RowState>();
211
+
212
+ for (const operation of operations) {
213
+ const schema = context.schemaFor(operation.collection);
214
+ const resolver: Resolver = {
215
+ schema,
216
+ now: context.now(),
217
+ async state(id) {
218
+ const staged = overlay.get(`${schema.name}:${id}`);
219
+ if (staged !== undefined) {
220
+ return staged;
221
+ }
222
+ const result = await context.connection.execute(
223
+ `SELECT deleted_at FROM ${quoted(schema)} WHERE id = ?`,
224
+ [id],
225
+ );
226
+ const row = result.rows[0];
227
+ if (!row) return 'absent';
228
+ return row.deleted_at === null || row.deleted_at === undefined ? 'live' : 'softDeleted';
229
+ },
230
+ stage(id, state) {
231
+ overlay.set(`${schema.name}:${id}`, state);
232
+ },
233
+ };
234
+
235
+ const resolved = await resolveOperation(resolver, operation);
236
+ commands.push(resolved.command);
237
+ outcomes.push(resolved.outcome);
238
+ }
239
+
240
+ await context.connection.executeBatch(commands);
241
+ return { applied: outcomes.length, outcomes };
242
+ }