@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,444 +1,517 @@
1
- /**
2
- * Opening and closing a database.
3
- *
4
- * The order of operations here is the design, not an implementation detail:
5
- *
6
- * 1. **Validate everything first.** Configuration, scope, and the migration
7
- * chain are checked before a file is touched, so a developer error is
8
- * reported against an untouched database (FR-006, FR-053).
9
- * 2. **Consult the catalog before opening the scope file.** A posture
10
- * mismatch is detectable there; discovering it from SQLCipher's "file is
11
- * not a database" would report corruption for what is actually an app
12
- * configuration change (FR-101).
13
- * 3. **Migrate before returning.** Nothing observes a half-migrated schema,
14
- * because there is no handle to observe it with until the chain completes
15
- * (FR-055). A failure returns no handle at all (FR-050).
16
- *
17
- * Scope is bound here and nowhere else. The handle carries its scope key and
18
- * no operation on it accepts a scope argument, so there is no `switchScope`
19
- * to misuse and no read that could address another scope (FR-008a, FR-015).
20
- * SC-002a's "a leak is not expressible" is this shape plus one file per
21
- * scope — not a check somewhere that could be forgotten.
22
- */
23
-
24
- import type { Attachments, DatabaseHandle } from '../contract/adapter';
25
- import type {
26
- AttachmentInput,
27
- AttachmentOwner,
28
- ByteRange,
29
- DeletionOutcome,
30
- ReconciliationReport,
31
- } from '../contract/attachment';
32
- import type { EffectivePosture } from '../contract/backup';
33
- import type { DatabaseConfig } from '../contract/config';
34
- import { DiagnosticCodes } from '../contract/errors';
35
- import type { IndexCoverageReport, Query } from '../contract/query';
36
- import type { StorableRecordData } from '../contract/record';
37
- import type { CollectionSchema } from '../contract/schema';
38
- import type { ResolvedScope } from '../contract/scope';
39
- import type {
40
- BatchOperation,
41
- BatchOutcome,
42
- CollectionOps,
43
- Transaction,
44
- } from '../contract/transaction';
45
- import { artifactAvailability } from '../artifacts/availability';
46
- import { cascadeForOwner } from '../artifacts/cascade';
47
- import { reconcileArtifacts } from '../artifacts/reconcile';
48
- import {
49
- ensureAttachmentTable,
50
- listReferencesFor,
51
- readArtifact,
52
- removeArtifact,
53
- writeArtifact,
54
- type ArtifactContext,
55
- } from '../artifacts/store';
56
- import { deriveArtifactKey } from '../crypto/derive';
57
- import { acquireKey, type KeyHandle } from '../crypto/key-provider';
58
- import { isCipherAvailable } from '../crypto/provider';
59
- import { raise } from '../diagnostics/errors';
60
- import { instrument } from '../diagnostics/dev-log';
61
- import { throwIfFaulted } from '../diagnostics/faults';
62
- import { runMigrations } from '../migration/executor';
63
- import { effectivePostureFor } from '../platform/backup-android-verify';
64
- import { ensureManagedRoot, iosPosture } from '../platform/backup-ios';
65
- import { layoutFor, type StorageLayout } from '../platform/location';
66
- import { withStableTiebreaker } from '../query/sort';
67
- import { indexSchemas } from '../schema/declare';
68
- import { validateConfig } from '../schema/validate-config';
69
- import { resolveScope } from '../scope/validate';
70
- import { runBatch } from '../sqlite/batch';
71
- import { ensureCatalog, readEntry, registerScope } from '../sqlite/catalog';
72
- import { assertReadable, openConnection, type Connection } from '../sqlite/connection';
73
- import { explainQuery } from '../sqlite/index-coverage';
74
- import { isEncryptionAvailable } from '../sqlite/native';
75
- import { addColumnSql, createIndexSql, createTableSql } from '../sqlite/row-codec';
76
- import { runInTransaction } from '../sqlite/transactions';
77
- import { text } from '../sqlite/values';
78
- import { acquireHandleSlot, releaseHandleSlot } from './budgets';
79
- import { createSqlCollection } from './collection';
80
- import { clearScopeInvalidation, isScopeInvalidated } from './deletion';
81
- import { createTransaction } from './transaction';
82
-
83
- export type OpenOptions = {
84
- now?: () => number;
85
- };
86
-
87
- /**
88
- * Bring the physical tables in line with the declared schema.
89
- *
90
- * Adding a column rather than recreating the table is what makes declaring a
91
- * new queryable field non-destructive: the payload already holds the value,
92
- * so the new column is backfilled from it and no record is rewritten by hand
93
- * (research.md R6).
94
- */
95
- async function ensureCollection(
96
- connection: Connection,
97
- schema: CollectionSchema,
98
- ): Promise<void> {
99
- await connection.execute(createTableSql(schema));
100
-
101
- const info = await connection.execute(
102
- `PRAGMA table_info("c_${schema.name}")`,
103
- );
104
- const present = new Set(info.rows.map(row => text(row.name)));
105
-
106
- for (const field of schema.fields ?? []) {
107
- const column = `f_${field.name}`;
108
- if (present.has(column)) {
109
- continue;
110
- }
111
- await connection.execute(addColumnSql(schema, field));
112
- await connection.execute(
113
- `UPDATE "c_${schema.name}" SET "${column}" = json_extract(payload, '$.${field.name}')`,
114
- );
115
- }
116
-
117
- for (const statement of createIndexSql(schema)) {
118
- await connection.execute(statement);
119
- }
120
- }
121
-
122
- function resolveEncryption(config: DatabaseConfig): boolean {
123
- if (config.encryption?.enabled !== true) {
124
- return false;
125
- }
126
- // Writing plaintext to a file the app believes is encrypted is the one
127
- // outcome worse than refusing to open (research.md R1a).
128
- if (!isEncryptionAvailable()) {
129
- raise(DiagnosticCodes.ENCRYPTION_UNAVAILABLE, {
130
- message: 'encryption was requested but SQLCipher is not compiled into this build',
131
- });
132
- }
133
- if (!isCipherAvailable()) {
134
- raise(DiagnosticCodes.ENCRYPTION_UNAVAILABLE, {
135
- message: 'encryption was requested but the cipher peer is not installed',
136
- });
137
- }
138
- return true;
139
- }
140
-
141
- async function assertPostureMatches(
142
- catalog: Connection,
143
- scope: ResolvedScope,
144
- encrypted: boolean,
145
- ): Promise<void> {
146
- const entry = await readEntry(catalog, scope.key);
147
- if (!entry) {
148
- return;
149
- }
150
- if (entry.deletionState === 'deleting') {
151
- // An interrupted deletion is reported rather than opened over: the
152
- // scope is neither present nor gone, and presenting it as healthy is
153
- // the one answer FR-063 forbids. `deleteScopeData` finishes the job.
154
- raise(DiagnosticCodes.SCOPE_DELETED, {
155
- message: 'a previous deletion of this scope was interrupted',
156
- detail: { scopeKey: scope.key },
157
- });
158
- }
159
- if (entry.encrypted !== encrypted) {
160
- // In-place conversion is not supported this phase (FR-103). Reporting
161
- // the mismatch lets an app migrate deliberately; opening would either
162
- // fail obscurely or write the wrong posture.
163
- raise(DiagnosticCodes.ENCRYPTION_MISMATCH, {
164
- message: 'encryption posture does not match the stored database',
165
- detail: { scopeKey: scope.key },
166
- });
167
- }
168
- }
169
-
170
- export async function openSqliteDatabase(
171
- config: DatabaseConfig,
172
- options: OpenOptions = {},
173
- ): Promise<DatabaseHandle> {
174
- const now = options.now ?? (() => Date.now());
175
-
176
- validateConfig(config);
177
- throwIfFaulted('open');
178
- const scope = resolveScope(config.scope);
179
- const encrypted = resolveEncryption(config);
180
- const includeInBackup = config.backup?.includeInPlatformBackup === true;
181
-
182
- const layout: StorageLayout = layoutFor(config.name);
183
- await ensureManagedRoot(layout, includeInBackup);
184
-
185
- const catalog = await openConnection({ path: layout.catalogPath });
186
- let key: KeyHandle | null = null;
187
- let connection: Connection | null = null;
188
- let released = false;
189
-
190
- const releaseConnection = async (): Promise<void> => {
191
- if (released || !connection) {
192
- return;
193
- }
194
- released = true;
195
- await connection.close();
196
- releaseHandleSlot();
197
- };
198
-
199
- try {
200
- await ensureCatalog(catalog);
201
- await assertPostureMatches(catalog, scope, encrypted);
202
-
203
- if (encrypted) {
204
- key = await acquireKey(config.encryption!, scope);
205
- }
206
-
207
- acquireHandleSlot();
208
- try {
209
- connection = await openConnection({
210
- path: layout.scopeDataPath(scope.key),
211
- ...(key ? { encryptionKey: key.value() } : {}),
212
- });
213
- } catch (error) {
214
- releaseHandleSlot();
215
- throw error;
216
- }
217
- await assertReadable(connection);
218
-
219
- const schemas = indexSchemas(config.collections);
220
- for (const schema of config.collections) {
221
- await ensureCollection(connection, schema);
222
- }
223
- await ensureAttachmentTable(connection);
224
-
225
- const scopeConnection = connection;
226
- const schemaVersion = await runMigrations({
227
- connection: scopeConnection,
228
- config,
229
- schemaFor: name => schemaFor(schemas, name),
230
- now,
231
- closeOnFailure: releaseConnection,
232
- });
233
-
234
- await registerScope(catalog, {
235
- scopeKey: scope.key,
236
- createdAt: now(),
237
- deletionState: 'active',
238
- backupPosture: includeInBackup ? 'included' : 'excluded',
239
- encrypted,
240
- });
241
- await catalog.close();
242
- clearScopeInvalidation(config.name, scope.key);
243
-
244
- return buildHandle({
245
- config,
246
- connection: scopeConnection,
247
- key,
248
- layout,
249
- now,
250
- schemaVersion,
251
- schemas,
252
- scope,
253
- posture: effectivePostureFor(layout, includeInBackup, iosPosture),
254
- });
255
- } catch (error) {
256
- // No usable handle after a failed open (FR-002). Anything already
257
- // acquired is released here rather than left for a caller who is about
258
- // to be handed an exception instead of a handle.
259
- key?.release();
260
- await releaseConnection();
261
- await catalog.close();
262
- throw error;
263
- }
264
- }
265
-
266
- function schemaFor(
267
- schemas: ReadonlyMap<string, CollectionSchema>,
268
- name: string,
269
- ): CollectionSchema {
270
- const schema = schemas.get(name);
271
- if (!schema) {
272
- raise(DiagnosticCodes.CONFIG_INVALID, {
273
- message: 'collection is not declared',
274
- detail: { collection: name },
275
- });
276
- }
277
- return schema;
278
- }
279
-
280
- type HandleContext = {
281
- config: DatabaseConfig;
282
- connection: Connection;
283
- key: KeyHandle | null;
284
- layout: StorageLayout;
285
- now(): number;
286
- schemaVersion: number;
287
- schemas: ReadonlyMap<string, CollectionSchema>;
288
- scope: ResolvedScope;
289
- posture: EffectivePosture;
290
- };
291
-
292
- function buildHandle(context: HandleContext): DatabaseHandle {
293
- const { config, connection, layout, scope, schemas } = context;
294
- let closed = false;
295
-
296
- const assertUsable = (): void => {
297
- if (closed) {
298
- raise(DiagnosticCodes.CLOSED_HANDLE, { message: 'handle is closed' });
299
- }
300
- if (isScopeInvalidated(config.name, scope.key)) {
301
- // The deletion did not wait for this handle, and this handle does
302
- // not get to keep answering from data that is gone (FR-059a).
303
- raise(DiagnosticCodes.SCOPE_DELETED, {
304
- message: 'scope was deleted beneath this handle',
305
- detail: { scopeKey: scope.key },
306
- });
307
- }
308
- };
309
-
310
- const artifacts: ArtifactContext = {
311
- executor: () => connection,
312
- layout,
313
- scopeKey: scope.key,
314
- artifactKey: attachmentId =>
315
- context.key ? deriveArtifactKey(context.key.value(), scope.key, attachmentId) : null,
316
- now: context.now,
317
- };
318
-
319
- const rawCollection = <T extends StorableRecordData>(name: string): CollectionOps<T> => {
320
- assertUsable();
321
- return instrument(
322
- name,
323
- 'sqlite',
324
- createSqlCollection<T>({
325
- executor: () => connection,
326
- schema: schemaFor(schemas, name),
327
- now: context.now,
328
- assertUsable,
329
- }) as unknown as Record<string, (...args: never[]) => unknown>,
330
- ) as unknown as CollectionOps<T>;
331
- };
332
-
333
- /**
334
- * Record deletion optionally takes its artifacts with it (FR-070).
335
- *
336
- * Off unless the app asks: a package that deleted a user's files as a side
337
- * effect of a record delete would be making a decision that is not its to
338
- * make.
339
- */
340
- const collectionFor = <T extends StorableRecordData>(name: string): CollectionOps<T> => {
341
- const ops = rawCollection<T>(name);
342
- if (config.cascadeAttachments !== true) {
343
- return ops;
344
- }
345
- return {
346
- ...ops,
347
- async delete(id: string) {
348
- const outcome = await ops.delete(id);
349
- await cascadeForOwner(artifacts, connection, { collection: name, id });
350
- return outcome;
351
- },
352
- };
353
- };
354
-
355
- const attachments: Attachments = {
356
- write(input: AttachmentInput) {
357
- assertUsable();
358
- return writeArtifact(artifacts, input);
359
- },
360
- read(id: string, range?: ByteRange) {
361
- assertUsable();
362
- return readArtifact(artifacts, id, range);
363
- },
364
- availability(id: string) {
365
- assertUsable();
366
- return artifactAvailability(artifacts, connection, id);
367
- },
368
- remove(id: string): Promise<DeletionOutcome> {
369
- assertUsable();
370
- return removeArtifact(artifacts, id);
371
- },
372
- listFor(owner: AttachmentOwner) {
373
- assertUsable();
374
- return listReferencesFor(connection, owner);
375
- },
376
- reconcile(options): Promise<ReconciliationReport> {
377
- assertUsable();
378
- return reconcileArtifacts(artifacts, connection, options);
379
- },
380
- };
381
-
382
- const handle: DatabaseHandle = {
383
- scopeKey: scope.key,
384
- schemaVersion: context.schemaVersion,
385
- backupPosture: context.posture,
386
- isEncrypted: context.key !== null,
387
-
388
- collection: collectionFor,
389
-
390
- async transaction<R>(fn: (tx: Transaction) => Promise<R>): Promise<R> {
391
- assertUsable();
392
- return runInTransaction(connection, async tx => {
393
- const managed = createTransaction({
394
- collection: collectionFor,
395
- rollback: () => tx.rollback(),
396
- });
397
- const result = await fn(managed.transaction);
398
- if (!managed.isSettled()) {
399
- throwIfFaulted('commit');
400
- }
401
- managed.settle();
402
- return result;
403
- });
404
- },
405
-
406
- batch(operations: BatchOperation[]): Promise<BatchOutcome> {
407
- assertUsable();
408
- return runBatch(
409
- {
410
- connection,
411
- schemaFor: name => schemaFor(schemas, name),
412
- now: context.now,
413
- },
414
- operations,
415
- );
416
- },
417
-
418
- attachments: () => attachments,
419
-
420
- explain(collection: string, query?: Query): Promise<IndexCoverageReport> {
421
- assertUsable();
422
- return explainQuery(
423
- connection,
424
- schemaFor(schemas, collection),
425
- query,
426
- withStableTiebreaker(query?.sort),
427
- );
428
- },
429
-
430
- async close(): Promise<void> {
431
- if (closed) {
432
- return;
433
- }
434
- closed = true;
435
- // The key goes first: a close that fails at the filesystem must not
436
- // leave key material alive in a handle nobody can use (FR-098).
437
- context.key?.release();
438
- await connection.close();
439
- releaseHandleSlot();
440
- },
441
- };
442
-
443
- return handle;
444
- }
1
+ /**
2
+ * Opening and closing a database.
3
+ *
4
+ * The order of operations here is the design, not an implementation detail:
5
+ *
6
+ * 1. **Validate everything first.** Configuration, scope, and the migration
7
+ * chain are checked before a file is touched, so a developer error is
8
+ * reported against an untouched database (FR-006, FR-053).
9
+ * 2. **Consult the catalog before opening the scope file.** A posture
10
+ * mismatch is detectable there; discovering it from SQLCipher's "file is
11
+ * not a database" would report corruption for what is actually an app
12
+ * configuration change (FR-101).
13
+ * 3. **Migrate before returning.** Nothing observes a half-migrated schema,
14
+ * because there is no handle to observe it with until the chain completes
15
+ * (FR-055). A failure returns no handle at all (FR-050).
16
+ *
17
+ * Scope is bound here and nowhere else. The handle carries its scope key and
18
+ * no operation on it accepts a scope argument, so there is no `switchScope`
19
+ * to misuse and no read that could address another scope (FR-008a, FR-015).
20
+ * SC-002a's "a leak is not expressible" is this shape plus one file per
21
+ * scope — not a check somewhere that could be forgotten.
22
+ */
23
+
24
+ import type { Attachments, DatabaseHandle } from '../contract/adapter';
25
+ import type {
26
+ AttachmentInput,
27
+ AttachmentOwner,
28
+ ByteRange,
29
+ DeletionOutcome,
30
+ ReconciliationReport,
31
+ } from '../contract/attachment';
32
+ import type { EffectivePosture } from '../contract/backup';
33
+ import type { DatabaseConfig } from '../contract/config';
34
+ import { DiagnosticCodes } from '../contract/errors';
35
+ import type { IndexCoverageReport, Query } from '../contract/query';
36
+ import type { StorableRecordData } from '../contract/record';
37
+ import type { CollectionSchema } from '../contract/schema';
38
+ import type { ResolvedScope } from '../contract/scope';
39
+ import type {
40
+ BatchOperation,
41
+ BatchOutcome,
42
+ CollectionOps,
43
+ Transaction,
44
+ } from '../contract/transaction';
45
+ import { artifactAvailability } from '../artifacts/availability';
46
+ import { cascadeForOwner, unlinkArtifact, type DeferredUnlinks } from '../artifacts/cascade';
47
+ import { reconcileArtifacts } from '../artifacts/reconcile';
48
+ import {
49
+ ensureAttachmentTable,
50
+ listReferencesFor,
51
+ readArtifact,
52
+ removeArtifact,
53
+ writeArtifact,
54
+ type ArtifactContext,
55
+ } from '../artifacts/store';
56
+ import { deriveArtifactKey } from '../crypto/derive';
57
+ import { acquireKey, type KeyHandle } from '../crypto/key-provider';
58
+ import { isCipherAvailable } from '../crypto/provider';
59
+ import { raise } from '../diagnostics/errors';
60
+ import { instrument } from '../diagnostics/dev-log';
61
+ import { throwIfFaulted } from '../diagnostics/faults';
62
+ import { runMigrations } from '../migration/executor';
63
+ import { effectivePostureFor } from '../platform/backup-android-verify';
64
+ import { ensureManagedRoot, iosPosture } from '../platform/backup-ios';
65
+ import { layoutFor, type StorageLayout } from '../platform/location';
66
+ import { withStableTiebreaker } from '../query/sort';
67
+ import { indexSchemas } from '../schema/declare';
68
+ import { validateConfig } from '../schema/validate-config';
69
+ import { resolveScope } from '../scope/validate';
70
+ import { runBatch } from '../sqlite/batch';
71
+ import { ensureCatalog, readEntry, registerScope } from '../sqlite/catalog';
72
+ import {
73
+ assertReadable,
74
+ openConnection,
75
+ type Connection,
76
+ type Executor,
77
+ } from '../sqlite/connection';
78
+ import { explainQuery } from '../sqlite/index-coverage';
79
+ import { isEncryptionAvailable } from '../sqlite/native';
80
+ import {
81
+ addColumnSql,
82
+ createIndexSql,
83
+ createTableSql,
84
+ physicalIndexName,
85
+ } from '../sqlite/row-codec';
86
+ import { runInTransaction } from '../sqlite/transactions';
87
+ import { text } from '../sqlite/values';
88
+ import { acquireHandleSlot, releaseHandleSlot } from './budgets';
89
+ import { createSqlCollection } from './collection';
90
+ import { clearScopeInvalidation, isScopeInvalidated } from './deletion';
91
+ import { createTransaction } from './transaction';
92
+
93
+ export type OpenOptions = {
94
+ now?: () => number;
95
+ };
96
+
97
+ /**
98
+ * Bring the physical tables in line with the declared schema.
99
+ *
100
+ * Adding a column rather than recreating the table is what makes declaring a
101
+ * new queryable field non-destructive: the payload already holds the value,
102
+ * so the new column is backfilled from it and no record is rewritten by hand
103
+ * (research.md R6).
104
+ */
105
+ async function ensureCollection(connection: Connection, schema: CollectionSchema): Promise<void> {
106
+ await connection.execute(createTableSql(schema));
107
+
108
+ const info = await connection.execute(`PRAGMA table_info("c_${schema.name}")`);
109
+ const present = new Set(info.rows.map(row => text(row.name)));
110
+
111
+ for (const field of schema.fields ?? []) {
112
+ const column = `f_${field.name}`;
113
+ if (present.has(column)) {
114
+ continue;
115
+ }
116
+ await connection.execute(addColumnSql(schema, field));
117
+ await connection.execute(
118
+ `UPDATE "c_${schema.name}" SET "${column}" = json_extract(payload, '$.${field.name}')`,
119
+ );
120
+ }
121
+
122
+ // Indexes created before 0.2.0 carry the bare declared name. Each one still
123
+ // attached to this table is dropped and recreated under its collection-scoped
124
+ // name; only indexes on this table are touched, and no row data moves.
125
+ const existing = await connection.execute(`PRAGMA index_list("c_${schema.name}")`);
126
+ const onTable = new Set(existing.rows.map(row => text(row.name)));
127
+ for (const index of schema.indexes ?? []) {
128
+ if (onTable.has(index.name) && index.name !== physicalIndexName(schema, index.name)) {
129
+ await connection.execute(`DROP INDEX IF EXISTS "${index.name}"`);
130
+ }
131
+ }
132
+
133
+ for (const statement of createIndexSql(schema)) {
134
+ await connection.execute(statement);
135
+ }
136
+ }
137
+
138
+ function resolveEncryption(config: DatabaseConfig): boolean {
139
+ if (config.encryption?.enabled !== true) {
140
+ return false;
141
+ }
142
+ // Writing plaintext to a file the app believes is encrypted is the one
143
+ // outcome worse than refusing to open (research.md R1a).
144
+ if (!isEncryptionAvailable()) {
145
+ raise(DiagnosticCodes.ENCRYPTION_UNAVAILABLE, {
146
+ message: 'encryption was requested but SQLCipher is not compiled into this build',
147
+ });
148
+ }
149
+ if (!isCipherAvailable()) {
150
+ raise(DiagnosticCodes.ENCRYPTION_UNAVAILABLE, {
151
+ message: 'encryption was requested but the cipher peer is not installed',
152
+ });
153
+ }
154
+ return true;
155
+ }
156
+
157
+ async function assertPostureMatches(
158
+ catalog: Connection,
159
+ scope: ResolvedScope,
160
+ encrypted: boolean,
161
+ ): Promise<void> {
162
+ const entry = await readEntry(catalog, scope.key);
163
+ if (!entry) {
164
+ return;
165
+ }
166
+ if (entry.deletionState === 'deleting') {
167
+ // An interrupted deletion is reported rather than opened over: the
168
+ // scope is neither present nor gone, and presenting it as healthy is
169
+ // the one answer FR-063 forbids. `deleteScopeData` finishes the job.
170
+ raise(DiagnosticCodes.SCOPE_DELETED, {
171
+ message: 'a previous deletion of this scope was interrupted',
172
+ detail: { scopeKey: scope.key },
173
+ });
174
+ }
175
+ if (entry.encrypted !== encrypted) {
176
+ // In-place conversion is not supported this phase (FR-103). Reporting
177
+ // the mismatch lets an app migrate deliberately; opening would either
178
+ // fail obscurely or write the wrong posture.
179
+ raise(DiagnosticCodes.ENCRYPTION_MISMATCH, {
180
+ message: 'encryption posture does not match the stored database',
181
+ detail: { scopeKey: scope.key },
182
+ });
183
+ }
184
+ }
185
+
186
+ export async function openSqliteDatabase(
187
+ config: DatabaseConfig,
188
+ options: OpenOptions = {},
189
+ ): Promise<DatabaseHandle> {
190
+ const now = options.now ?? (() => Date.now());
191
+
192
+ validateConfig(config);
193
+ throwIfFaulted('open');
194
+ const scope = resolveScope(config.scope);
195
+ const encrypted = resolveEncryption(config);
196
+ const includeInBackup = config.backup?.includeInPlatformBackup === true;
197
+
198
+ const layout: StorageLayout = layoutFor(config.name);
199
+ await ensureManagedRoot(layout, includeInBackup);
200
+
201
+ const catalog = await openConnection({ path: layout.catalogPath });
202
+ let key: KeyHandle | null = null;
203
+ let connection: Connection | null = null;
204
+ let released = false;
205
+
206
+ const releaseConnection = async (): Promise<void> => {
207
+ if (released || !connection) {
208
+ return;
209
+ }
210
+ released = true;
211
+ await connection.close();
212
+ releaseHandleSlot();
213
+ };
214
+
215
+ try {
216
+ await ensureCatalog(catalog);
217
+ await assertPostureMatches(catalog, scope, encrypted);
218
+
219
+ if (encrypted) {
220
+ key = await acquireKey(config.encryption!, scope);
221
+ }
222
+
223
+ acquireHandleSlot();
224
+ try {
225
+ connection = await openConnection({
226
+ path: layout.scopeDataPath(scope.key),
227
+ ...(key ? { encryptionKey: key.value() } : {}),
228
+ });
229
+ } catch (error) {
230
+ releaseHandleSlot();
231
+ throw error;
232
+ }
233
+ await assertReadable(connection);
234
+
235
+ const schemas = indexSchemas(config.collections);
236
+ for (const schema of config.collections) {
237
+ await ensureCollection(connection, schema);
238
+ }
239
+ await ensureAttachmentTable(connection);
240
+
241
+ const scopeConnection = connection;
242
+ const schemaVersion = await runMigrations({
243
+ connection: scopeConnection,
244
+ config,
245
+ schemaFor: name => schemaFor(schemas, name),
246
+ now,
247
+ closeOnFailure: releaseConnection,
248
+ });
249
+
250
+ await registerScope(catalog, {
251
+ scopeKey: scope.key,
252
+ createdAt: now(),
253
+ deletionState: 'active',
254
+ backupPosture: includeInBackup ? 'included' : 'excluded',
255
+ encrypted,
256
+ });
257
+ await catalog.close();
258
+ clearScopeInvalidation(config.name, scope.key);
259
+
260
+ return buildHandle({
261
+ config,
262
+ connection: scopeConnection,
263
+ key,
264
+ layout,
265
+ now,
266
+ schemaVersion,
267
+ schemas,
268
+ scope,
269
+ posture: effectivePostureFor(layout, includeInBackup, iosPosture),
270
+ });
271
+ } catch (error) {
272
+ // No usable handle after a failed open (FR-002). Anything already
273
+ // acquired is released here rather than left for a caller who is about
274
+ // to be handed an exception instead of a handle.
275
+ key?.release();
276
+ await releaseConnection();
277
+ await catalog.close();
278
+ throw error;
279
+ }
280
+ }
281
+
282
+ function schemaFor(schemas: ReadonlyMap<string, CollectionSchema>, name: string): CollectionSchema {
283
+ const schema = schemas.get(name);
284
+ if (!schema) {
285
+ raise(DiagnosticCodes.CONFIG_INVALID, {
286
+ message: 'collection is not declared',
287
+ detail: { collection: name },
288
+ });
289
+ }
290
+ return schema;
291
+ }
292
+
293
+ type HandleContext = {
294
+ config: DatabaseConfig;
295
+ connection: Connection;
296
+ key: KeyHandle | null;
297
+ layout: StorageLayout;
298
+ now(): number;
299
+ schemaVersion: number;
300
+ schemas: ReadonlyMap<string, CollectionSchema>;
301
+ scope: ResolvedScope;
302
+ posture: EffectivePosture;
303
+ };
304
+
305
+ function buildHandle(context: HandleContext): DatabaseHandle {
306
+ const { config, connection, layout, scope, schemas } = context;
307
+ let closed = false;
308
+
309
+ const assertUsable = (): void => {
310
+ if (closed) {
311
+ raise(DiagnosticCodes.CLOSED_HANDLE, { message: 'handle is closed' });
312
+ }
313
+ if (isScopeInvalidated(config.name, scope.key)) {
314
+ // The deletion did not wait for this handle, and this handle does
315
+ // not get to keep answering from data that is gone (FR-059a).
316
+ raise(DiagnosticCodes.SCOPE_DELETED, {
317
+ message: 'scope was deleted beneath this handle',
318
+ detail: { scopeKey: scope.key },
319
+ });
320
+ }
321
+ };
322
+
323
+ // Everything below is built against an explicit executor: the connection for
324
+ // the handle, the transaction's own executor inside `transaction`. Nothing
325
+ // infers which one from the connection's state, which is how a write from
326
+ // outside used to be swallowed by an in-flight transaction (LDB-01).
327
+ const artifactsOn = (executor: Executor): ArtifactContext => ({
328
+ executor: () => executor,
329
+ layout,
330
+ scopeKey: scope.key,
331
+ artifactKey: attachmentId =>
332
+ context.key ? deriveArtifactKey(context.key.value(), scope.key, attachmentId) : null,
333
+ now: context.now,
334
+ });
335
+
336
+ const rawCollection = <T extends StorableRecordData>(
337
+ name: string,
338
+ executor: Executor,
339
+ ): CollectionOps<T> => {
340
+ assertUsable();
341
+ return instrument(
342
+ name,
343
+ 'sqlite',
344
+ createSqlCollection<T>({
345
+ executor: () => executor,
346
+ schema: schemaFor(schemas, name),
347
+ now: context.now,
348
+ assertUsable,
349
+ }) as unknown as Record<string, (...args: never[]) => unknown>,
350
+ ) as unknown as CollectionOps<T>;
351
+ };
352
+
353
+ /**
354
+ * Record deletion optionally takes its artifacts with it (FR-070).
355
+ *
356
+ * Off unless the app asks: a package that deleted a user's files as a side
357
+ * effect of a record delete would be making a decision that is not its to
358
+ * make. The outcome carries what was removed and what could not be.
359
+ */
360
+ const collectionOn = <T extends StorableRecordData>(
361
+ name: string,
362
+ executor: Executor,
363
+ deferred?: DeferredUnlinks,
364
+ ): CollectionOps<T> => {
365
+ const ops = rawCollection<T>(name, executor);
366
+ if (config.cascadeAttachments !== true) {
367
+ return ops;
368
+ }
369
+ return {
370
+ ...ops,
371
+ async delete(id: string) {
372
+ const outcome = await ops.delete(id);
373
+ const cascade = await cascadeForOwner(
374
+ artifactsOn(executor),
375
+ executor,
376
+ { collection: name, id },
377
+ deferred,
378
+ );
379
+ return { ...outcome, cascade };
380
+ },
381
+ };
382
+ };
383
+
384
+ const attachmentsOn = (executor: Executor, deferred?: DeferredUnlinks): Attachments => {
385
+ const artifacts = artifactsOn(executor);
386
+ return {
387
+ write(input: AttachmentInput) {
388
+ assertUsable();
389
+ return writeArtifact(artifacts, input);
390
+ },
391
+ read(id: string, range?: ByteRange) {
392
+ assertUsable();
393
+ return readArtifact(artifacts, id, range);
394
+ },
395
+ availability(id: string) {
396
+ assertUsable();
397
+ return artifactAvailability(artifacts, executor, id);
398
+ },
399
+ remove(id: string): Promise<DeletionOutcome> {
400
+ assertUsable();
401
+ return removeArtifact(artifacts, id, deferred);
402
+ },
403
+ listFor(owner: AttachmentOwner) {
404
+ assertUsable();
405
+ return listReferencesFor(executor, owner);
406
+ },
407
+ reconcile(options): Promise<ReconciliationReport> {
408
+ assertUsable();
409
+ return reconcileArtifacts(artifacts, executor, options);
410
+ },
411
+ };
412
+ };
413
+
414
+ const collectionFor = <T extends StorableRecordData>(name: string): CollectionOps<T> =>
415
+ collectionOn<T>(name, connection);
416
+ const attachments = attachmentsOn(connection);
417
+
418
+ const handle: DatabaseHandle = {
419
+ scopeKey: scope.key,
420
+ schemaVersion: context.schemaVersion,
421
+ backupPosture: context.posture,
422
+ isEncrypted: context.key !== null,
423
+
424
+ collection: collectionFor,
425
+
426
+ async transaction<R>(fn: (tx: Transaction) => Promise<R>): Promise<R> {
427
+ assertUsable();
428
+ const pendingUnlinks: string[] = [];
429
+ const deferred: DeferredUnlinks = { add: path => pendingUnlinks.push(path) };
430
+ let aborted = false;
431
+ const result = await runInTransaction(connection, async tx => {
432
+ const managed = createTransaction({
433
+ collection: name => collectionOn(name, tx, deferred),
434
+ attachments: () => attachmentsOn(tx, deferred),
435
+ rollback: async () => {
436
+ aborted = true;
437
+ await tx.rollback();
438
+ },
439
+ });
440
+ const value = await fn(managed.transaction);
441
+ if (!managed.isSettled()) {
442
+ throwIfFaulted('commit');
443
+ }
444
+ managed.settle();
445
+ return value;
446
+ });
447
+ // Committed (a thrown body never gets here). Only now are files whose
448
+ // references the transaction deleted safe to remove; one that cannot be
449
+ // removed stays on disk as an orphan reconciliation reports.
450
+ if (!aborted) {
451
+ for (const path of pendingUnlinks) {
452
+ await unlinkArtifact(path);
453
+ }
454
+ }
455
+ return result;
456
+ },
457
+
458
+ async batch(operations: BatchOperation[]): Promise<BatchOutcome> {
459
+ assertUsable();
460
+ const outcome = await runBatch(
461
+ {
462
+ connection,
463
+ schemaFor: name => schemaFor(schemas, name),
464
+ now: context.now,
465
+ },
466
+ operations,
467
+ );
468
+ if (config.cascadeAttachments !== true) {
469
+ return outcome;
470
+ }
471
+ // The batch has committed, so its hard deletes cascade exactly as a
472
+ // collection delete does. Skipping them left every attachment of a
473
+ // batch-deleted record behind as an orphan.
474
+ const artifacts = artifactsOn(connection);
475
+ const outcomes = [];
476
+ for (const [index, operation] of operations.entries()) {
477
+ const written = outcome.outcomes[index]!;
478
+ if (operation.type !== 'delete') {
479
+ outcomes.push(written);
480
+ continue;
481
+ }
482
+ const cascade = await cascadeForOwner(artifacts, connection, {
483
+ collection: operation.collection,
484
+ id: operation.id,
485
+ });
486
+ outcomes.push({ ...written, cascade });
487
+ }
488
+ return { ...outcome, outcomes };
489
+ },
490
+
491
+ attachments: () => attachments,
492
+
493
+ explain(collection: string, query?: Query): Promise<IndexCoverageReport> {
494
+ assertUsable();
495
+ return explainQuery(
496
+ connection,
497
+ schemaFor(schemas, collection),
498
+ query,
499
+ withStableTiebreaker(query?.sort),
500
+ );
501
+ },
502
+
503
+ async close(): Promise<void> {
504
+ if (closed) {
505
+ return;
506
+ }
507
+ closed = true;
508
+ // The key goes first: a close that fails at the filesystem must not
509
+ // leave key material alive in a handle nobody can use (FR-098).
510
+ context.key?.release();
511
+ await connection.close();
512
+ releaseHandleSlot();
513
+ },
514
+ };
515
+
516
+ return handle;
517
+ }