@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.
- package/CHANGELOG.md +122 -102
- package/README.md +728 -728
- package/package.json +59 -62
- package/skills/sdk-rn-local-db/SKILL.md +0 -1
- package/src/adapter/budgets.ts +67 -67
- package/src/adapter/collection.ts +303 -298
- package/src/adapter/deletion.ts +252 -252
- package/src/adapter/index.ts +64 -68
- package/src/adapter/lifecycle.ts +517 -520
- package/src/adapter/transaction.ts +71 -71
- package/src/artifacts/availability.ts +67 -67
- package/src/artifacts/cascade.ts +114 -114
- package/src/artifacts/paths.ts +29 -29
- package/src/artifacts/reconcile.ts +63 -63
- package/src/artifacts/store.ts +334 -345
- package/src/contract/adapter.ts +76 -78
- package/src/contract/attachment.ts +75 -78
- package/src/contract/backup.ts +28 -28
- package/src/contract/config.ts +54 -54
- package/src/contract/encryption.ts +34 -34
- package/src/contract/errors.ts +76 -76
- package/src/contract/migration.ts +42 -42
- package/src/contract/query.ts +81 -81
- package/src/contract/record.ts +46 -51
- package/src/contract/schema.ts +36 -36
- package/src/contract/scope.ts +36 -36
- package/src/contract/transaction.ts +52 -54
- package/src/crypto/chunked-cipher.ts +153 -150
- package/src/crypto/derive.ts +64 -64
- package/src/crypto/key-provider.ts +78 -78
- package/src/crypto/provider.ts +81 -81
- package/src/diagnostics/dev-log.ts +112 -108
- package/src/diagnostics/errors.ts +62 -66
- package/src/diagnostics/faults.ts +58 -64
- package/src/diagnostics/map-native.ts +108 -106
- package/src/diagnostics/redact.ts +137 -141
- package/src/index.ts +80 -110
- package/src/migration/executor.ts +132 -136
- package/src/migration/ledger.ts +95 -97
- package/src/migration/validate-chain.ts +65 -65
- package/src/platform/backup-android-verify.ts +88 -88
- package/src/platform/backup-ios.ts +48 -48
- package/src/platform/bytes.ts +144 -144
- package/src/platform/fs.ts +89 -89
- package/src/platform/location.ts +135 -135
- package/src/query/budgets.ts +54 -54
- package/src/query/compare.ts +91 -91
- package/src/query/cursor.ts +100 -100
- package/src/query/filter.ts +82 -82
- package/src/query/paginate.ts +51 -51
- package/src/query/sort.ts +55 -56
- package/src/query/validate.ts +171 -171
- package/src/schema/declare.ts +42 -45
- package/src/schema/validate-config.ts +158 -158
- package/src/scope/hash.ts +131 -134
- package/src/scope/normalize.ts +83 -90
- package/src/scope/validate.ts +83 -83
- package/src/sqlite/adapter.ts +26 -26
- package/src/sqlite/batch.ts +242 -242
- package/src/sqlite/catalog.ts +105 -108
- package/src/sqlite/connection.ts +227 -227
- package/src/sqlite/index-coverage.ts +54 -56
- package/src/sqlite/native.ts +83 -83
- package/src/sqlite/row-codec.ts +217 -221
- package/src/sqlite/sql-builder.ts +244 -244
- package/src/sqlite/transaction-queue.ts +87 -87
- package/src/sqlite/transactions.ts +33 -33
- package/src/sqlite/values.ts +24 -24
- package/src/testing/conformance/attachments.ts +138 -136
- package/src/testing/conformance/encryption.ts +150 -150
- package/src/testing/conformance/index.ts +161 -161
- package/src/testing/conformance/lifecycle.ts +99 -99
- package/src/testing/conformance/migrations.ts +177 -177
- package/src/testing/conformance/queries.ts +204 -167
- package/src/testing/conformance/records.ts +169 -169
- package/src/testing/conformance/scopes.ts +86 -88
- package/src/testing/conformance/transactions.ts +149 -149
- package/src/testing/faults.ts +21 -21
- package/src/testing/index.ts +17 -21
- package/src/testing/memory-adapter/collection.ts +214 -219
- package/src/testing/memory-adapter/index.ts +510 -492
- package/src/testing/memory-adapter/migrations.ts +117 -119
- package/src/testing/memory-adapter/reset.ts +50 -50
- package/src/testing/memory-adapter/store.ts +128 -134
|
@@ -1,492 +1,510 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The in-memory adapter.
|
|
3
|
-
*
|
|
4
|
-
* Not a convenience: op-sqlite is native and unreachable from Jest, so this
|
|
5
|
-
* is the only way the package's own guarantees are testable in CI
|
|
6
|
-
* (research.md R8). Documented divergences are listed in the package README.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import { createTransactionQueue } from '../../sqlite/transaction-queue';
|
|
10
|
-
import type {
|
|
11
|
-
AttachmentInput,
|
|
12
|
-
AttachmentReference,
|
|
13
|
-
AttachmentOwner,
|
|
14
|
-
ByteRange,
|
|
15
|
-
DeletionOutcome,
|
|
16
|
-
ReconciliationReport,
|
|
17
|
-
} from '../../contract/attachment';
|
|
18
|
-
import type { EffectivePosture } from '../../contract/backup';
|
|
19
|
-
import type {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
import
|
|
25
|
-
import type {
|
|
26
|
-
import {
|
|
27
|
-
import {
|
|
28
|
-
import {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
import {
|
|
35
|
-
import {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
} from './
|
|
43
|
-
import {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
//
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
const
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
const
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
assertValidAttachmentId(id);
|
|
222
|
-
const
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
return
|
|
234
|
-
},
|
|
235
|
-
async
|
|
236
|
-
assertOpen();
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
if (
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
return
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
const
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
id
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
const
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
settled = true;
|
|
367
|
-
rollback();
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
1
|
+
/**
|
|
2
|
+
* The in-memory adapter.
|
|
3
|
+
*
|
|
4
|
+
* Not a convenience: op-sqlite is native and unreachable from Jest, so this
|
|
5
|
+
* is the only way the package's own guarantees are testable in CI
|
|
6
|
+
* (research.md R8). Documented divergences are listed in the package README.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { createTransactionQueue } from '../../sqlite/transaction-queue';
|
|
10
|
+
import type {
|
|
11
|
+
AttachmentInput,
|
|
12
|
+
AttachmentReference,
|
|
13
|
+
AttachmentOwner,
|
|
14
|
+
ByteRange,
|
|
15
|
+
DeletionOutcome,
|
|
16
|
+
ReconciliationReport,
|
|
17
|
+
} from '../../contract/attachment';
|
|
18
|
+
import type { EffectivePosture } from '../../contract/backup';
|
|
19
|
+
import type {
|
|
20
|
+
DatabaseConfig,
|
|
21
|
+
DatabaseTargetConfig,
|
|
22
|
+
ScopeTargetConfig,
|
|
23
|
+
} from '../../contract/config';
|
|
24
|
+
import { DiagnosticCodes } from '../../contract/errors';
|
|
25
|
+
import type { IndexCoverageReport, Query } from '../../contract/query';
|
|
26
|
+
import type { StorableRecordData } from '../../contract/record';
|
|
27
|
+
import type { CollectionSchema } from '../../contract/schema';
|
|
28
|
+
import type {
|
|
29
|
+
BatchOperation,
|
|
30
|
+
BatchOutcome,
|
|
31
|
+
CollectionOps,
|
|
32
|
+
Transaction,
|
|
33
|
+
} from '../../contract/transaction';
|
|
34
|
+
import type { Attachments, DatabaseHandle, StorageAdapter } from '../../contract/adapter';
|
|
35
|
+
import { acquireKey } from '../../crypto/key-provider';
|
|
36
|
+
import { instrument } from '../../diagnostics/dev-log';
|
|
37
|
+
import { raise } from '../../diagnostics/errors';
|
|
38
|
+
import { assertValidAttachmentId } from '../../platform/location';
|
|
39
|
+
import { assertBatchWithinBudget, assertArtifactWithinBudget } from '../../query/budgets';
|
|
40
|
+
import { validateConfig } from '../../schema/validate-config';
|
|
41
|
+
import { resolveScope } from '../../scope/validate';
|
|
42
|
+
import { runMigrations } from './migrations';
|
|
43
|
+
import { createMemoryCollection } from './collection';
|
|
44
|
+
import {
|
|
45
|
+
collectionMap,
|
|
46
|
+
dropDatabase,
|
|
47
|
+
dropScope,
|
|
48
|
+
existingScopeStore,
|
|
49
|
+
scopeStoreFor,
|
|
50
|
+
type MemoryScopeStore,
|
|
51
|
+
} from './store';
|
|
52
|
+
import { throwIfFaulted } from '../faults';
|
|
53
|
+
|
|
54
|
+
export type MemoryAdapterOptions = {
|
|
55
|
+
/** Fixed clock, so timestamp assertions are deterministic. */
|
|
56
|
+
now?: () => number;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
function schemaIndex(collections: CollectionSchema[]): Map<string, CollectionSchema> {
|
|
60
|
+
return new Map(collections.map(collection => [collection.name, collection]));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function cloneCollections(store: MemoryScopeStore): Map<
|
|
64
|
+
string,
|
|
65
|
+
Map<
|
|
66
|
+
string,
|
|
67
|
+
{
|
|
68
|
+
id: string;
|
|
69
|
+
payload: string;
|
|
70
|
+
createdAt?: number;
|
|
71
|
+
updatedAt?: number;
|
|
72
|
+
deletedAt?: number | null;
|
|
73
|
+
}
|
|
74
|
+
>
|
|
75
|
+
> {
|
|
76
|
+
const copy = new Map<string, Map<string, never>>();
|
|
77
|
+
for (const [name, rows] of store.collections) {
|
|
78
|
+
copy.set(name, new Map([...rows].map(([id, row]) => [id, { ...row } as never])));
|
|
79
|
+
}
|
|
80
|
+
return copy as never;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function createMemoryAdapter(options: MemoryAdapterOptions = {}): StorageAdapter {
|
|
84
|
+
const now = options.now ?? (() => Date.now());
|
|
85
|
+
|
|
86
|
+
async function open(config: DatabaseConfig): Promise<DatabaseHandle> {
|
|
87
|
+
validateConfig(config);
|
|
88
|
+
throwIfFaulted('open');
|
|
89
|
+
|
|
90
|
+
const scope = resolveScope(config.scope);
|
|
91
|
+
const existing = existingScopeStore(config.name, scope.key);
|
|
92
|
+
if (existing?.deletionState === 'deleting') {
|
|
93
|
+
// A deletion that was interrupted must never look complete.
|
|
94
|
+
raise(DiagnosticCodes.SCOPE_DELETED, {
|
|
95
|
+
message: 'scope deletion was interrupted',
|
|
96
|
+
detail: { scopeKey: scope.key },
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const store = scopeStoreFor(config.name, scope.key);
|
|
101
|
+
const encryptionRequested = config.encryption?.enabled === true;
|
|
102
|
+
|
|
103
|
+
if (store.schemaVersion > 0 && store.encrypted !== encryptionRequested) {
|
|
104
|
+
raise(DiagnosticCodes.ENCRYPTION_MISMATCH, {
|
|
105
|
+
message: 'encryption posture does not match the stored database',
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
if (encryptionRequested) {
|
|
109
|
+
// The same acquisition path the on-device adapter uses, so a locked
|
|
110
|
+
// keychain and a provider returning nothing stay as distinguishable
|
|
111
|
+
// here as they are on a phone (SC-020b). Released immediately: the
|
|
112
|
+
// in-memory store has nothing to encrypt with it.
|
|
113
|
+
const key = await acquireKey(config.encryption!, scope);
|
|
114
|
+
key.release();
|
|
115
|
+
}
|
|
116
|
+
store.encrypted = encryptionRequested;
|
|
117
|
+
store.backupIncluded = config.backup?.includeInPlatformBackup === true;
|
|
118
|
+
|
|
119
|
+
const schemas = schemaIndex(config.collections);
|
|
120
|
+
for (const collection of config.collections) {
|
|
121
|
+
collectionMap(store, collection.name);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
await runMigrations(store, config, now);
|
|
125
|
+
|
|
126
|
+
let closed = false;
|
|
127
|
+
const assertOpen = (): void => {
|
|
128
|
+
if (closed) {
|
|
129
|
+
raise(DiagnosticCodes.CLOSED_HANDLE, { message: 'handle is closed' });
|
|
130
|
+
}
|
|
131
|
+
if (store.deletionState === 'deleting') {
|
|
132
|
+
raise(DiagnosticCodes.SCOPE_DELETED, {
|
|
133
|
+
message: 'scope was deleted beneath this handle',
|
|
134
|
+
detail: { scopeKey: scope.key },
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* The same isolation the SQLite connection gives: a transaction holds the
|
|
141
|
+
* handle until it settles, and work through the handle meanwhile waits
|
|
142
|
+
* rather than landing inside the transaction's snapshot and vanishing
|
|
143
|
+
* with its rollback. Only what the transaction hands out runs inside it.
|
|
144
|
+
*/
|
|
145
|
+
const queue = createTransactionQueue();
|
|
146
|
+
const queued = <O extends object>(ops: O): O =>
|
|
147
|
+
Object.fromEntries(
|
|
148
|
+
Object.entries(ops).map(([key, value]) => [
|
|
149
|
+
key,
|
|
150
|
+
typeof value === 'function'
|
|
151
|
+
? (...args: unknown[]) =>
|
|
152
|
+
queue.run(() => (value as (...a: unknown[]) => Promise<unknown>)(...args))
|
|
153
|
+
: value,
|
|
154
|
+
]),
|
|
155
|
+
) as O;
|
|
156
|
+
|
|
157
|
+
/** Artifact deletions held back until the transaction that caused them commits. */
|
|
158
|
+
type Deferred = { add(id: string): void };
|
|
159
|
+
|
|
160
|
+
/** FR-070 for the memory adapter, with the SQLite adapter's ordering and reporting. */
|
|
161
|
+
const cascadeFor = (collection: string, id: string, deferred?: Deferred): DeletionOutcome => {
|
|
162
|
+
const removedArtifacts: string[] = [];
|
|
163
|
+
for (const reference of [...store.attachments.values()]) {
|
|
164
|
+
if (reference.owner?.collection !== collection || reference.owner?.id !== id) continue;
|
|
165
|
+
store.attachments.delete(reference.id);
|
|
166
|
+
if (deferred) {
|
|
167
|
+
deferred.add(reference.id);
|
|
168
|
+
} else {
|
|
169
|
+
store.artifacts.delete(reference.id);
|
|
170
|
+
}
|
|
171
|
+
removedArtifacts.push(reference.id);
|
|
172
|
+
}
|
|
173
|
+
return { removedRecords: 0, removedArtifacts, orphanedReferences: [], failedRemovals: [] };
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
const rawCollectionFor = <T extends StorableRecordData>(
|
|
177
|
+
name: string,
|
|
178
|
+
deferred?: Deferred,
|
|
179
|
+
): CollectionOps<T> => {
|
|
180
|
+
assertOpen();
|
|
181
|
+
const schema = schemas.get(name);
|
|
182
|
+
if (!schema) {
|
|
183
|
+
raise(DiagnosticCodes.CONFIG_INVALID, {
|
|
184
|
+
message: 'collection is not declared',
|
|
185
|
+
detail: { collection: name },
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
const ops = instrument(
|
|
189
|
+
name,
|
|
190
|
+
'memory',
|
|
191
|
+
createMemoryCollection<T>({ store, schema, now, assertOpen }) as unknown as Record<
|
|
192
|
+
string,
|
|
193
|
+
(...args: never[]) => unknown
|
|
194
|
+
>,
|
|
195
|
+
) as unknown as CollectionOps<T>;
|
|
196
|
+
if (config.cascadeAttachments !== true) {
|
|
197
|
+
return ops;
|
|
198
|
+
}
|
|
199
|
+
return {
|
|
200
|
+
...ops,
|
|
201
|
+
async delete(id: string) {
|
|
202
|
+
const outcome = await ops.delete(id);
|
|
203
|
+
return { ...outcome, cascade: cascadeFor(name, id, deferred) };
|
|
204
|
+
},
|
|
205
|
+
};
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
const collectionFor = <T extends StorableRecordData>(name: string): CollectionOps<T> =>
|
|
209
|
+
queued(rawCollectionFor<T>(name));
|
|
210
|
+
|
|
211
|
+
const backupPosture: EffectivePosture = {
|
|
212
|
+
requested: store.backupIncluded ? 'included' : 'excluded',
|
|
213
|
+
observed: store.backupIncluded ? 'included' : 'excluded',
|
|
214
|
+
enforcedBy: 'package',
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
const rawAttachments = (deferred?: Deferred): Attachments => ({
|
|
218
|
+
async write(input: AttachmentInput): Promise<AttachmentReference> {
|
|
219
|
+
assertOpen();
|
|
220
|
+
throwIfFaulted('attachmentWrite');
|
|
221
|
+
assertValidAttachmentId(input.id);
|
|
222
|
+
const bytes = Buffer.from(input.content, 'base64');
|
|
223
|
+
assertArtifactWithinBudget(input.id, bytes.length);
|
|
224
|
+
const reference: AttachmentReference = {
|
|
225
|
+
id: input.id,
|
|
226
|
+
owner: input.owner,
|
|
227
|
+
contentType: input.contentType,
|
|
228
|
+
size: bytes.length,
|
|
229
|
+
createdAt: now(),
|
|
230
|
+
};
|
|
231
|
+
store.artifacts.set(input.id, input.content);
|
|
232
|
+
store.attachments.set(input.id, reference);
|
|
233
|
+
return reference;
|
|
234
|
+
},
|
|
235
|
+
async read(id: string, range?: ByteRange): Promise<string> {
|
|
236
|
+
assertOpen();
|
|
237
|
+
// No paths here to escape, but an id the device build would refuse
|
|
238
|
+
// has to be refused here too, or a consumer's passing tests stop
|
|
239
|
+
// predicting the device (FR-096).
|
|
240
|
+
assertValidAttachmentId(id);
|
|
241
|
+
const content = store.artifacts.get(id);
|
|
242
|
+
if (content === undefined) {
|
|
243
|
+
raise(DiagnosticCodes.ATTACHMENT_MISSING, {
|
|
244
|
+
message: 'artifact is missing',
|
|
245
|
+
detail: { attachmentId: id },
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
if (!range) {
|
|
249
|
+
return content;
|
|
250
|
+
}
|
|
251
|
+
const bytes = Buffer.from(content, 'base64');
|
|
252
|
+
return bytes.subarray(range.offset, range.offset + range.length).toString('base64');
|
|
253
|
+
},
|
|
254
|
+
async availability(id) {
|
|
255
|
+
assertOpen();
|
|
256
|
+
const reference = store.attachments.get(id);
|
|
257
|
+
if (!reference) {
|
|
258
|
+
return { id, state: 'missing' };
|
|
259
|
+
}
|
|
260
|
+
return store.artifacts.has(id)
|
|
261
|
+
? { id, state: 'available', size: reference.size }
|
|
262
|
+
: { id, state: 'missing' };
|
|
263
|
+
},
|
|
264
|
+
async remove(id: string): Promise<DeletionOutcome> {
|
|
265
|
+
assertOpen();
|
|
266
|
+
assertValidAttachmentId(id);
|
|
267
|
+
if (deferred) {
|
|
268
|
+
const hadReference = store.attachments.delete(id);
|
|
269
|
+
if (hadReference) deferred.add(id);
|
|
270
|
+
return {
|
|
271
|
+
removedRecords: 0,
|
|
272
|
+
removedArtifacts: hadReference ? [id] : [],
|
|
273
|
+
orphanedReferences: [],
|
|
274
|
+
failedRemovals: [],
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
const removed = store.artifacts.delete(id);
|
|
278
|
+
store.attachments.delete(id);
|
|
279
|
+
return {
|
|
280
|
+
removedRecords: 0,
|
|
281
|
+
removedArtifacts: removed ? [id] : [],
|
|
282
|
+
orphanedReferences: [],
|
|
283
|
+
failedRemovals: [],
|
|
284
|
+
};
|
|
285
|
+
},
|
|
286
|
+
async listFor(owner: AttachmentOwner): Promise<AttachmentReference[]> {
|
|
287
|
+
assertOpen();
|
|
288
|
+
return [...store.attachments.values()].filter(
|
|
289
|
+
reference =>
|
|
290
|
+
reference.owner?.collection === owner.collection && reference.owner?.id === owner.id,
|
|
291
|
+
);
|
|
292
|
+
},
|
|
293
|
+
async reconcile(reconcileOptions): Promise<ReconciliationReport> {
|
|
294
|
+
assertOpen();
|
|
295
|
+
const orphanedArtifacts = [...store.artifacts.keys()].filter(
|
|
296
|
+
id => !store.attachments.has(id),
|
|
297
|
+
);
|
|
298
|
+
const danglingReferences = [...store.attachments.keys()].filter(
|
|
299
|
+
id => !store.artifacts.has(id),
|
|
300
|
+
);
|
|
301
|
+
const removedArtifacts: string[] = [];
|
|
302
|
+
if (reconcileOptions?.removeOrphans) {
|
|
303
|
+
for (const id of orphanedArtifacts) {
|
|
304
|
+
store.artifacts.delete(id);
|
|
305
|
+
removedArtifacts.push(id);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
return { orphanedArtifacts, danglingReferences, removedArtifacts };
|
|
309
|
+
},
|
|
310
|
+
});
|
|
311
|
+
const attachments = queued(rawAttachments());
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* One transaction against the store. Snapshot-and-restore rather than a
|
|
315
|
+
* write log: simpler to reason about, and rollback fidelity is what US4
|
|
316
|
+
* tests. Runs only inside `exclusive`, so the snapshot never captures,
|
|
317
|
+
* nor its restore erases, a write made from outside.
|
|
318
|
+
*/
|
|
319
|
+
const runTransaction = async <R>(fn: (tx: Transaction) => Promise<R>): Promise<R> => {
|
|
320
|
+
const snapshot = cloneCollections(store);
|
|
321
|
+
const attachmentSnapshot = new Map(store.attachments);
|
|
322
|
+
const pendingArtifactRemovals: string[] = [];
|
|
323
|
+
const deferred: Deferred = { add: id => pendingArtifactRemovals.push(id) };
|
|
324
|
+
let settled = false;
|
|
325
|
+
|
|
326
|
+
const assertLive = (): void => {
|
|
327
|
+
if (settled) {
|
|
328
|
+
raise(DiagnosticCodes.TRANSACTION_STATE, {
|
|
329
|
+
message: 'transaction already settled',
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
};
|
|
333
|
+
const rollback = (): void => {
|
|
334
|
+
store.collections = snapshot as never;
|
|
335
|
+
store.attachments = attachmentSnapshot;
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
// A collection taken from the transaction keeps refusing once it
|
|
339
|
+
// settles, as the SQLite executor does; otherwise a write after abort
|
|
340
|
+
// would land straight in the store.
|
|
341
|
+
const live = <O extends object>(ops: O): O =>
|
|
342
|
+
Object.fromEntries(
|
|
343
|
+
Object.entries(ops).map(([key, value]) => [
|
|
344
|
+
key,
|
|
345
|
+
typeof value === 'function'
|
|
346
|
+
? // Async so a refusal rejects, as the SQLite executor's does.
|
|
347
|
+
async (...args: unknown[]) => {
|
|
348
|
+
assertLive();
|
|
349
|
+
return (value as (...a: unknown[]) => unknown)(...args);
|
|
350
|
+
}
|
|
351
|
+
: value,
|
|
352
|
+
]),
|
|
353
|
+
) as O;
|
|
354
|
+
|
|
355
|
+
const tx: Transaction = {
|
|
356
|
+
collection: <T extends StorableRecordData>(name: string) => {
|
|
357
|
+
assertLive();
|
|
358
|
+
return live(rawCollectionFor<T>(name, deferred));
|
|
359
|
+
},
|
|
360
|
+
attachments: () => {
|
|
361
|
+
assertLive();
|
|
362
|
+
return rawAttachments(deferred);
|
|
363
|
+
},
|
|
364
|
+
async abort() {
|
|
365
|
+
assertLive();
|
|
366
|
+
settled = true;
|
|
367
|
+
rollback();
|
|
368
|
+
},
|
|
369
|
+
};
|
|
370
|
+
|
|
371
|
+
try {
|
|
372
|
+
const result = await fn(tx);
|
|
373
|
+
if (!settled) {
|
|
374
|
+
throwIfFaulted('commit');
|
|
375
|
+
settled = true;
|
|
376
|
+
// Committed: the artifacts whose references went may go too.
|
|
377
|
+
for (const id of pendingArtifactRemovals) {
|
|
378
|
+
store.artifacts.delete(id);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
return result;
|
|
382
|
+
} catch (error) {
|
|
383
|
+
if (!settled) {
|
|
384
|
+
settled = true;
|
|
385
|
+
rollback();
|
|
386
|
+
}
|
|
387
|
+
throw error;
|
|
388
|
+
}
|
|
389
|
+
};
|
|
390
|
+
|
|
391
|
+
const handle: DatabaseHandle = {
|
|
392
|
+
scopeKey: scope.key,
|
|
393
|
+
schemaVersion: store.schemaVersion,
|
|
394
|
+
backupPosture,
|
|
395
|
+
isEncrypted: store.encrypted,
|
|
396
|
+
collection: collectionFor,
|
|
397
|
+
|
|
398
|
+
transaction<R>(fn: (tx: Transaction) => Promise<R>): Promise<R> {
|
|
399
|
+
assertOpen();
|
|
400
|
+
return queue.runTransaction(() => runTransaction(fn));
|
|
401
|
+
},
|
|
402
|
+
|
|
403
|
+
batch(operations: BatchOperation[]): Promise<BatchOutcome> {
|
|
404
|
+
assertOpen();
|
|
405
|
+
assertBatchWithinBudget(operations.length);
|
|
406
|
+
return queue.runTransaction(() =>
|
|
407
|
+
runTransaction(async (tx: Transaction) => {
|
|
408
|
+
const outcomes = [];
|
|
409
|
+
for (const operation of operations) {
|
|
410
|
+
const ops = tx.collection(operation.collection);
|
|
411
|
+
switch (operation.type) {
|
|
412
|
+
case 'insert':
|
|
413
|
+
outcomes.push(await ops.insert(operation.record));
|
|
414
|
+
break;
|
|
415
|
+
case 'update':
|
|
416
|
+
outcomes.push(await ops.update(operation.id, { data: operation.data }));
|
|
417
|
+
break;
|
|
418
|
+
case 'upsert':
|
|
419
|
+
outcomes.push(await ops.upsert(operation.record));
|
|
420
|
+
break;
|
|
421
|
+
case 'delete':
|
|
422
|
+
outcomes.push(await ops.delete(operation.id));
|
|
423
|
+
break;
|
|
424
|
+
case 'softDelete':
|
|
425
|
+
outcomes.push(await ops.softDelete(operation.id));
|
|
426
|
+
break;
|
|
427
|
+
default:
|
|
428
|
+
break;
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
return { applied: outcomes.length, outcomes };
|
|
432
|
+
}),
|
|
433
|
+
);
|
|
434
|
+
},
|
|
435
|
+
|
|
436
|
+
attachments: () => attachments,
|
|
437
|
+
|
|
438
|
+
async explain(collection: string, _query?: Query): Promise<IndexCoverageReport> {
|
|
439
|
+
assertOpen();
|
|
440
|
+
// Documented divergence: index declarations are validated but
|
|
441
|
+
// have no performance effect in memory (FR-096).
|
|
442
|
+
return {
|
|
443
|
+
collection,
|
|
444
|
+
usesIndex: false,
|
|
445
|
+
detail: 'memory adapter does not use indexes',
|
|
446
|
+
};
|
|
447
|
+
},
|
|
448
|
+
|
|
449
|
+
async close(): Promise<void> {
|
|
450
|
+
closed = true;
|
|
451
|
+
},
|
|
452
|
+
};
|
|
453
|
+
|
|
454
|
+
return handle;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* Backs both `deleteScopeData` and `resetScope`. They differ in intent and
|
|
459
|
+
* in what they let a consumer conclude, not in what they do to the store —
|
|
460
|
+
* keeping them as separate exported names is what makes the destructive one
|
|
461
|
+
* impossible to call by accident.
|
|
462
|
+
*/
|
|
463
|
+
async function removeScope(config: ScopeTargetConfig): Promise<DeletionOutcome> {
|
|
464
|
+
const scope = resolveScope(config.scope);
|
|
465
|
+
const store = existingScopeStore(config.name, scope.key);
|
|
466
|
+
if (!store) {
|
|
467
|
+
// Deleting nothing is success, not an error (FR-062).
|
|
468
|
+
return {
|
|
469
|
+
scopeKey: scope.key,
|
|
470
|
+
removedRecords: 0,
|
|
471
|
+
removedArtifacts: [],
|
|
472
|
+
orphanedReferences: [],
|
|
473
|
+
failedRemovals: [],
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
store.deletionState = 'deleting';
|
|
477
|
+
let removedRecords = 0;
|
|
478
|
+
for (const rows of store.collections.values()) {
|
|
479
|
+
removedRecords += rows.size;
|
|
480
|
+
}
|
|
481
|
+
const removedArtifacts = [...store.artifacts.keys()];
|
|
482
|
+
dropScope(config.name, scope.key);
|
|
483
|
+
return {
|
|
484
|
+
scopeKey: scope.key,
|
|
485
|
+
removedRecords,
|
|
486
|
+
removedArtifacts,
|
|
487
|
+
orphanedReferences: [],
|
|
488
|
+
failedRemovals: [],
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
return {
|
|
493
|
+
name: 'memory',
|
|
494
|
+
open,
|
|
495
|
+
deleteScopeData: config => removeScope(config),
|
|
496
|
+
resetScope: config => removeScope(config),
|
|
497
|
+
async deleteDatabase(config: DatabaseTargetConfig): Promise<DeletionOutcome> {
|
|
498
|
+
dropDatabase(config.name);
|
|
499
|
+
return {
|
|
500
|
+
removedRecords: 0,
|
|
501
|
+
removedArtifacts: [],
|
|
502
|
+
orphanedReferences: [],
|
|
503
|
+
failedRemovals: [],
|
|
504
|
+
};
|
|
505
|
+
},
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
export { Budgets } from '../../contract/config';
|
|
510
|
+
export { activeFaults } from '../faults';
|