@chipmobilesdk/rn-local-db 0.2.0 → 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 -102
  2. package/README.md +728 -728
  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 -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 +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 -144
  44. package/src/platform/fs.ts +89 -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 +83 -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 +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 -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,242 +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
- /**
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
- }
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
+ }