@chipmobilesdk/rn-local-db 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +23 -1
- package/README.md +30 -11
- package/package.json +1 -1
- package/skills/sdk-rn-local-db/SKILL.md +1 -1
- package/src/adapter/budgets.ts +1 -10
- package/src/adapter/collection.ts +38 -66
- package/src/adapter/deletion.ts +48 -0
- package/src/adapter/handle.ts +311 -0
- package/src/adapter/lifecycle.ts +10 -338
- package/src/adapter/schema-sync.ts +155 -0
- package/src/artifacts/cascade.ts +0 -14
- package/src/artifacts/store.ts +60 -10
- package/src/contract/attachment.ts +24 -3
- package/src/contract/migration.ts +4 -1
- package/src/diagnostics/dev-log.ts +5 -1
- package/src/migration/executor.ts +27 -6
- package/src/migration/ledger.ts +18 -27
- package/src/platform/fs.ts +2 -8
- package/src/platform/location.ts +0 -3
- package/src/query/budgets.ts +32 -5
- package/src/schema/validate-config.ts +3 -53
- package/src/sqlite/batch.ts +40 -51
- package/src/sqlite/connection.ts +0 -9
- package/src/sqlite/row-codec.ts +5 -2
- package/src/sqlite/statements.ts +74 -0
- package/src/testing/index.ts +2 -0
- package/src/testing/memory-adapter/attachments.ts +146 -0
- package/src/testing/memory-adapter/index.ts +13 -111
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The handle an open returns: collections, transactions, batches and
|
|
3
|
+
* attachments over one connection, bound to one scope.
|
|
4
|
+
*
|
|
5
|
+
* Scope is bound here and nowhere else. The handle carries its scope key and
|
|
6
|
+
* no operation on it accepts a scope argument, so there is no `switchScope`
|
|
7
|
+
* to misuse and no read that could address another scope (FR-008a, FR-015).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { Attachments, DatabaseHandle } from '../contract/adapter';
|
|
11
|
+
import type {
|
|
12
|
+
AttachmentInput,
|
|
13
|
+
AttachmentOwner,
|
|
14
|
+
ByteRange,
|
|
15
|
+
DeletionOutcome,
|
|
16
|
+
ReconciliationReport,
|
|
17
|
+
} from '../contract/attachment';
|
|
18
|
+
import type { EffectivePosture } from '../contract/backup';
|
|
19
|
+
import type { DatabaseConfig } from '../contract/config';
|
|
20
|
+
import { DiagnosticCodes } from '../contract/errors';
|
|
21
|
+
import type { IndexCoverageReport, Query } from '../contract/query';
|
|
22
|
+
import type { StorableRecordData } from '../contract/record';
|
|
23
|
+
import type { CollectionSchema } from '../contract/schema';
|
|
24
|
+
import type { ResolvedScope } from '../contract/scope';
|
|
25
|
+
import type {
|
|
26
|
+
BatchOperation,
|
|
27
|
+
BatchOutcome,
|
|
28
|
+
CollectionOps,
|
|
29
|
+
Transaction,
|
|
30
|
+
} from '../contract/transaction';
|
|
31
|
+
import { artifactAvailability } from '../artifacts/availability';
|
|
32
|
+
import { cascadeForOwner, unlinkArtifact, type DeferredUnlinks } from '../artifacts/cascade';
|
|
33
|
+
import { reconcileArtifacts } from '../artifacts/reconcile';
|
|
34
|
+
import {
|
|
35
|
+
listReferencesFor,
|
|
36
|
+
readArtifact,
|
|
37
|
+
removeArtifact,
|
|
38
|
+
writeArtifact,
|
|
39
|
+
type ArtifactContext,
|
|
40
|
+
} from '../artifacts/store';
|
|
41
|
+
import { deriveArtifactKey } from '../crypto/derive';
|
|
42
|
+
import type { KeyHandle } from '../crypto/key-provider';
|
|
43
|
+
import { raise } from '../diagnostics/errors';
|
|
44
|
+
import { instrument } from '../diagnostics/dev-log';
|
|
45
|
+
import { throwIfFaulted } from '../diagnostics/faults';
|
|
46
|
+
import type { StorageLayout } from '../platform/location';
|
|
47
|
+
import { withStableTiebreaker } from '../query/sort';
|
|
48
|
+
import { runBatch } from '../sqlite/batch';
|
|
49
|
+
import type { Connection, Executor } from '../sqlite/connection';
|
|
50
|
+
import { explainQuery } from '../sqlite/index-coverage';
|
|
51
|
+
import { runInTransaction } from '../sqlite/transactions';
|
|
52
|
+
import { releaseHandleSlot } from './budgets';
|
|
53
|
+
import { createSqlCollection } from './collection';
|
|
54
|
+
import { isScopeInvalidated, registerOpenHandle } from './deletion';
|
|
55
|
+
import { createTransaction } from './transaction';
|
|
56
|
+
|
|
57
|
+
export function schemaFor(
|
|
58
|
+
schemas: ReadonlyMap<string, CollectionSchema>,
|
|
59
|
+
name: string,
|
|
60
|
+
): CollectionSchema {
|
|
61
|
+
const schema = schemas.get(name);
|
|
62
|
+
if (!schema) {
|
|
63
|
+
raise(DiagnosticCodes.CONFIG_INVALID, {
|
|
64
|
+
message: 'collection is not declared',
|
|
65
|
+
detail: { collection: name },
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
return schema;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export type HandleContext = {
|
|
72
|
+
config: DatabaseConfig;
|
|
73
|
+
connection: Connection;
|
|
74
|
+
key: KeyHandle | null;
|
|
75
|
+
layout: StorageLayout;
|
|
76
|
+
now(): number;
|
|
77
|
+
schemaVersion: number;
|
|
78
|
+
schemas: ReadonlyMap<string, CollectionSchema>;
|
|
79
|
+
scope: ResolvedScope;
|
|
80
|
+
posture: EffectivePosture;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
export function buildHandle(context: HandleContext): DatabaseHandle {
|
|
84
|
+
const { config, connection, layout, scope, schemas } = context;
|
|
85
|
+
let closed = false;
|
|
86
|
+
// Set when a deletion took the connection away rather than the app closing it.
|
|
87
|
+
let revoked = false;
|
|
88
|
+
|
|
89
|
+
const releaseNative = async (): Promise<void> => {
|
|
90
|
+
// The key goes first: a close that fails at the filesystem must not
|
|
91
|
+
// leave key material alive in a handle nobody can use (FR-098).
|
|
92
|
+
context.key?.release();
|
|
93
|
+
await connection.close();
|
|
94
|
+
releaseHandleSlot();
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const unregister = registerOpenHandle(config.name, scope.key, async () => {
|
|
98
|
+
if (closed) {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
closed = true;
|
|
102
|
+
revoked = true;
|
|
103
|
+
await releaseNative();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
const assertUsable = (): void => {
|
|
107
|
+
if (closed && !revoked) {
|
|
108
|
+
raise(DiagnosticCodes.CLOSED_HANDLE, { message: 'handle is closed' });
|
|
109
|
+
}
|
|
110
|
+
if (revoked || isScopeInvalidated(config.name, scope.key)) {
|
|
111
|
+
// The deletion did not wait for this handle, and this handle does
|
|
112
|
+
// not get to keep answering from data that is gone (FR-059a).
|
|
113
|
+
raise(DiagnosticCodes.SCOPE_DELETED, {
|
|
114
|
+
message: 'scope was deleted beneath this handle',
|
|
115
|
+
detail: { scopeKey: scope.key },
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
// Everything below is built against an explicit executor: the connection for
|
|
121
|
+
// the handle, the transaction's own executor inside `transaction`. Nothing
|
|
122
|
+
// infers which one from the connection's state, which is how a write from
|
|
123
|
+
// outside used to be swallowed by an in-flight transaction (LDB-01).
|
|
124
|
+
const artifactsOn = (executor: Executor): ArtifactContext => ({
|
|
125
|
+
executor: () => executor,
|
|
126
|
+
layout,
|
|
127
|
+
scopeKey: scope.key,
|
|
128
|
+
artifactKey: attachmentId =>
|
|
129
|
+
context.key ? deriveArtifactKey(context.key.value(), scope.key, attachmentId) : null,
|
|
130
|
+
now: context.now,
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
const rawCollection = <T extends StorableRecordData>(
|
|
134
|
+
name: string,
|
|
135
|
+
executor: Executor,
|
|
136
|
+
): CollectionOps<T> => {
|
|
137
|
+
assertUsable();
|
|
138
|
+
return instrument(
|
|
139
|
+
name,
|
|
140
|
+
'sqlite',
|
|
141
|
+
createSqlCollection<T>({
|
|
142
|
+
executor: () => executor,
|
|
143
|
+
schema: schemaFor(schemas, name),
|
|
144
|
+
now: context.now,
|
|
145
|
+
assertUsable,
|
|
146
|
+
}),
|
|
147
|
+
);
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Record deletion optionally takes its artifacts with it (FR-070).
|
|
152
|
+
*
|
|
153
|
+
* Off unless the app asks: a package that deleted a user's files as a side
|
|
154
|
+
* effect of a record delete would be making a decision that is not its to
|
|
155
|
+
* make. The outcome carries what was removed and what could not be.
|
|
156
|
+
*/
|
|
157
|
+
const collectionOn = <T extends StorableRecordData>(
|
|
158
|
+
name: string,
|
|
159
|
+
executor: Executor,
|
|
160
|
+
deferred?: DeferredUnlinks,
|
|
161
|
+
): CollectionOps<T> => {
|
|
162
|
+
const ops = rawCollection<T>(name, executor);
|
|
163
|
+
if (config.cascadeAttachments !== true) {
|
|
164
|
+
return ops;
|
|
165
|
+
}
|
|
166
|
+
return {
|
|
167
|
+
...ops,
|
|
168
|
+
async delete(id: string) {
|
|
169
|
+
const outcome = await ops.delete(id);
|
|
170
|
+
const cascade = await cascadeForOwner(
|
|
171
|
+
artifactsOn(executor),
|
|
172
|
+
executor,
|
|
173
|
+
{ collection: name, id },
|
|
174
|
+
deferred,
|
|
175
|
+
);
|
|
176
|
+
return { ...outcome, cascade };
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const attachmentsOn = (executor: Executor, deferred?: DeferredUnlinks): Attachments => {
|
|
182
|
+
const artifacts = artifactsOn(executor);
|
|
183
|
+
return {
|
|
184
|
+
write(input: AttachmentInput) {
|
|
185
|
+
assertUsable();
|
|
186
|
+
return writeArtifact(artifacts, input);
|
|
187
|
+
},
|
|
188
|
+
read(id: string, range?: ByteRange) {
|
|
189
|
+
assertUsable();
|
|
190
|
+
return readArtifact(artifacts, id, range);
|
|
191
|
+
},
|
|
192
|
+
availability(id: string) {
|
|
193
|
+
assertUsable();
|
|
194
|
+
return artifactAvailability(artifacts, executor, id);
|
|
195
|
+
},
|
|
196
|
+
remove(id: string): Promise<DeletionOutcome> {
|
|
197
|
+
assertUsable();
|
|
198
|
+
return removeArtifact(artifacts, id, deferred);
|
|
199
|
+
},
|
|
200
|
+
listFor(owner: AttachmentOwner) {
|
|
201
|
+
assertUsable();
|
|
202
|
+
return listReferencesFor(executor, owner);
|
|
203
|
+
},
|
|
204
|
+
reconcile(options): Promise<ReconciliationReport> {
|
|
205
|
+
assertUsable();
|
|
206
|
+
return reconcileArtifacts(artifacts, executor, options);
|
|
207
|
+
},
|
|
208
|
+
};
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
const collectionFor = <T extends StorableRecordData>(name: string): CollectionOps<T> =>
|
|
212
|
+
collectionOn<T>(name, connection);
|
|
213
|
+
const attachments = attachmentsOn(connection);
|
|
214
|
+
|
|
215
|
+
const handle: DatabaseHandle = {
|
|
216
|
+
scopeKey: scope.key,
|
|
217
|
+
schemaVersion: context.schemaVersion,
|
|
218
|
+
backupPosture: context.posture,
|
|
219
|
+
isEncrypted: context.key !== null,
|
|
220
|
+
|
|
221
|
+
collection: collectionFor,
|
|
222
|
+
|
|
223
|
+
async transaction<R>(fn: (tx: Transaction) => Promise<R>): Promise<R> {
|
|
224
|
+
assertUsable();
|
|
225
|
+
const pendingUnlinks: string[] = [];
|
|
226
|
+
const deferred: DeferredUnlinks = { add: path => pendingUnlinks.push(path) };
|
|
227
|
+
let aborted = false;
|
|
228
|
+
const result = await runInTransaction(connection, async tx => {
|
|
229
|
+
const managed = createTransaction({
|
|
230
|
+
collection: name => collectionOn(name, tx, deferred),
|
|
231
|
+
attachments: () => attachmentsOn(tx, deferred),
|
|
232
|
+
rollback: async () => {
|
|
233
|
+
aborted = true;
|
|
234
|
+
await tx.rollback();
|
|
235
|
+
},
|
|
236
|
+
});
|
|
237
|
+
const value = await fn(managed.transaction);
|
|
238
|
+
if (!managed.isSettled()) {
|
|
239
|
+
throwIfFaulted('commit');
|
|
240
|
+
}
|
|
241
|
+
managed.settle();
|
|
242
|
+
return value;
|
|
243
|
+
});
|
|
244
|
+
// Committed (a thrown body never gets here). Only now are files whose
|
|
245
|
+
// references the transaction deleted safe to remove; one that cannot be
|
|
246
|
+
// removed stays on disk as an orphan reconciliation reports.
|
|
247
|
+
if (!aborted) {
|
|
248
|
+
for (const path of pendingUnlinks) {
|
|
249
|
+
await unlinkArtifact(path);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
return result;
|
|
253
|
+
},
|
|
254
|
+
|
|
255
|
+
async batch(operations: BatchOperation[]): Promise<BatchOutcome> {
|
|
256
|
+
assertUsable();
|
|
257
|
+
const outcome = await runBatch(
|
|
258
|
+
{
|
|
259
|
+
connection,
|
|
260
|
+
schemaFor: name => schemaFor(schemas, name),
|
|
261
|
+
now: context.now,
|
|
262
|
+
},
|
|
263
|
+
operations,
|
|
264
|
+
);
|
|
265
|
+
if (config.cascadeAttachments !== true) {
|
|
266
|
+
return outcome;
|
|
267
|
+
}
|
|
268
|
+
// The batch has committed, so its hard deletes cascade exactly as a
|
|
269
|
+
// collection delete does. Skipping them left every attachment of a
|
|
270
|
+
// batch-deleted record behind as an orphan.
|
|
271
|
+
const artifacts = artifactsOn(connection);
|
|
272
|
+
const outcomes = [];
|
|
273
|
+
for (const [index, operation] of operations.entries()) {
|
|
274
|
+
const written = outcome.outcomes[index]!;
|
|
275
|
+
if (operation.type !== 'delete') {
|
|
276
|
+
outcomes.push(written);
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
const cascade = await cascadeForOwner(artifacts, connection, {
|
|
280
|
+
collection: operation.collection,
|
|
281
|
+
id: operation.id,
|
|
282
|
+
});
|
|
283
|
+
outcomes.push({ ...written, cascade });
|
|
284
|
+
}
|
|
285
|
+
return { ...outcome, outcomes };
|
|
286
|
+
},
|
|
287
|
+
|
|
288
|
+
attachments: () => attachments,
|
|
289
|
+
|
|
290
|
+
explain(collection: string, query?: Query): Promise<IndexCoverageReport> {
|
|
291
|
+
assertUsable();
|
|
292
|
+
return explainQuery(
|
|
293
|
+
connection,
|
|
294
|
+
schemaFor(schemas, collection),
|
|
295
|
+
query,
|
|
296
|
+
withStableTiebreaker(query?.sort),
|
|
297
|
+
);
|
|
298
|
+
},
|
|
299
|
+
|
|
300
|
+
async close(): Promise<void> {
|
|
301
|
+
if (closed) {
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
closed = true;
|
|
305
|
+
unregister();
|
|
306
|
+
await releaseNative();
|
|
307
|
+
},
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
return handle;
|
|
311
|
+
}
|
package/src/adapter/lifecycle.ts
CHANGED
|
@@ -14,127 +14,36 @@
|
|
|
14
14
|
* because there is no handle to observe it with until the chain completes
|
|
15
15
|
* (FR-055). A failure returns no handle at all (FR-050).
|
|
16
16
|
*
|
|
17
|
-
*
|
|
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.
|
|
17
|
+
* The handle itself is built in `handle.ts`.
|
|
22
18
|
*/
|
|
23
19
|
|
|
24
|
-
import type {
|
|
25
|
-
import type {
|
|
26
|
-
AttachmentInput,
|
|
27
|
-
AttachmentOwner,
|
|
28
|
-
ByteRange,
|
|
29
|
-
DeletionOutcome,
|
|
30
|
-
ReconciliationReport,
|
|
31
|
-
} from '../contract/attachment';
|
|
32
|
-
import type { EffectivePosture } from '../contract/backup';
|
|
20
|
+
import type { DatabaseHandle } from '../contract/adapter';
|
|
33
21
|
import type { DatabaseConfig } from '../contract/config';
|
|
34
22
|
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
23
|
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
24
|
import { acquireKey, type KeyHandle } from '../crypto/key-provider';
|
|
58
25
|
import { isCipherAvailable } from '../crypto/provider';
|
|
59
26
|
import { raise } from '../diagnostics/errors';
|
|
60
|
-
import { instrument } from '../diagnostics/dev-log';
|
|
61
27
|
import { throwIfFaulted } from '../diagnostics/faults';
|
|
62
28
|
import { runMigrations } from '../migration/executor';
|
|
63
29
|
import { effectivePostureFor } from '../platform/backup-android-verify';
|
|
64
30
|
import { ensureManagedRoot, iosPosture } from '../platform/backup-ios';
|
|
65
31
|
import { layoutFor, type StorageLayout } from '../platform/location';
|
|
66
|
-
import { withStableTiebreaker } from '../query/sort';
|
|
67
32
|
import { indexSchemas } from '../schema/declare';
|
|
68
33
|
import { validateConfig } from '../schema/validate-config';
|
|
69
34
|
import { resolveScope } from '../scope/validate';
|
|
70
|
-
import { runBatch } from '../sqlite/batch';
|
|
71
35
|
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';
|
|
36
|
+
import { assertReadable, openConnection, type Connection } from '../sqlite/connection';
|
|
79
37
|
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
38
|
import { acquireHandleSlot, releaseHandleSlot } from './budgets';
|
|
89
|
-
import {
|
|
90
|
-
import {
|
|
91
|
-
import {
|
|
39
|
+
import { clearScopeInvalidation } from './deletion';
|
|
40
|
+
import { buildHandle, schemaFor } from './handle';
|
|
41
|
+
import { syncSchema } from './schema-sync';
|
|
92
42
|
|
|
93
43
|
export type OpenOptions = {
|
|
94
44
|
now?: () => number;
|
|
95
45
|
};
|
|
96
46
|
|
|
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
47
|
function resolveEncryption(config: DatabaseConfig): boolean {
|
|
139
48
|
if (config.encryption?.enabled !== true) {
|
|
140
49
|
return false;
|
|
@@ -233,10 +142,8 @@ export async function openSqliteDatabase(
|
|
|
233
142
|
await assertReadable(connection);
|
|
234
143
|
|
|
235
144
|
const schemas = indexSchemas(config.collections);
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
}
|
|
239
|
-
await ensureAttachmentTable(connection);
|
|
145
|
+
// No DDL at all when the declaration is the one already applied (LDB-07).
|
|
146
|
+
const schema = await syncSchema(connection, config);
|
|
240
147
|
|
|
241
148
|
const scopeConnection = connection;
|
|
242
149
|
const schemaVersion = await runMigrations({
|
|
@@ -245,7 +152,9 @@ export async function openSqliteDatabase(
|
|
|
245
152
|
schemaFor: name => schemaFor(schemas, name),
|
|
246
153
|
now,
|
|
247
154
|
closeOnFailure: releaseConnection,
|
|
155
|
+
ledgerReady: schema.unchanged,
|
|
248
156
|
});
|
|
157
|
+
await schema.record();
|
|
249
158
|
|
|
250
159
|
await registerScope(catalog, {
|
|
251
160
|
scopeKey: scope.key,
|
|
@@ -278,240 +187,3 @@ export async function openSqliteDatabase(
|
|
|
278
187
|
throw error;
|
|
279
188
|
}
|
|
280
189
|
}
|
|
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
|
-
}
|