@chipmobilesdk/rn-local-db 0.1.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 +52 -0
- package/README.md +726 -0
- package/package.json +61 -0
- package/src/adapter/budgets.ts +67 -0
- package/src/adapter/collection.ts +298 -0
- package/src/adapter/deletion.ts +238 -0
- package/src/adapter/index.ts +68 -0
- package/src/adapter/lifecycle.ts +444 -0
- package/src/adapter/transaction.ts +65 -0
- package/src/artifacts/availability.ts +69 -0
- package/src/artifacts/cascade.ts +78 -0
- package/src/artifacts/paths.ts +29 -0
- package/src/artifacts/reconcile.ts +63 -0
- package/src/artifacts/store.ts +323 -0
- package/src/contract/adapter.ts +78 -0
- package/src/contract/attachment.ts +72 -0
- package/src/contract/backup.ts +28 -0
- package/src/contract/config.ts +54 -0
- package/src/contract/encryption.ts +34 -0
- package/src/contract/errors.ts +76 -0
- package/src/contract/migration.ts +40 -0
- package/src/contract/query.ts +81 -0
- package/src/contract/record.ts +42 -0
- package/src/contract/schema.ts +36 -0
- package/src/contract/scope.ts +36 -0
- package/src/contract/transaction.ts +46 -0
- package/src/crypto/chunked-cipher.ts +144 -0
- package/src/crypto/derive.ts +63 -0
- package/src/crypto/key-provider.ts +78 -0
- package/src/crypto/provider.ts +81 -0
- package/src/diagnostics/dev-log.ts +108 -0
- package/src/diagnostics/errors.ts +66 -0
- package/src/diagnostics/faults.ts +64 -0
- package/src/diagnostics/map-native.ts +106 -0
- package/src/diagnostics/redact.ts +141 -0
- package/src/index.ts +110 -0
- package/src/migration/executor.ts +136 -0
- package/src/migration/ledger.ts +97 -0
- package/src/migration/validate-chain.ts +65 -0
- package/src/platform/backup-android-verify.ts +88 -0
- package/src/platform/backup-ios.ts +48 -0
- package/src/platform/fs.ts +89 -0
- package/src/platform/location.ts +135 -0
- package/src/query/budgets.ts +57 -0
- package/src/query/compare.ts +91 -0
- package/src/query/cursor.ts +99 -0
- package/src/query/filter.ts +82 -0
- package/src/query/paginate.ts +51 -0
- package/src/query/sort.ts +56 -0
- package/src/query/validate.ts +171 -0
- package/src/schema/declare.ts +45 -0
- package/src/schema/validate-config.ts +158 -0
- package/src/scope/hash.ts +134 -0
- package/src/scope/normalize.ts +90 -0
- package/src/scope/validate.ts +83 -0
- package/src/sqlite/adapter.ts +26 -0
- package/src/sqlite/batch.ts +232 -0
- package/src/sqlite/catalog.ts +108 -0
- package/src/sqlite/connection.ts +205 -0
- package/src/sqlite/index-coverage.ts +53 -0
- package/src/sqlite/native.ts +83 -0
- package/src/sqlite/row-codec.ts +201 -0
- package/src/sqlite/sql-builder.ts +244 -0
- package/src/sqlite/transactions.ts +33 -0
- package/src/sqlite/values.ts +24 -0
- package/src/testing/conformance/attachments.ts +136 -0
- package/src/testing/conformance/encryption.ts +150 -0
- package/src/testing/conformance/index.ts +161 -0
- package/src/testing/conformance/lifecycle.ts +99 -0
- package/src/testing/conformance/migrations.ts +177 -0
- package/src/testing/conformance/queries.ts +167 -0
- package/src/testing/conformance/records.ts +169 -0
- package/src/testing/conformance/scopes.ts +88 -0
- package/src/testing/conformance/transactions.ts +149 -0
- package/src/testing/faults.ts +21 -0
- package/src/testing/index.ts +21 -0
- package/src/testing/memory-adapter/collection.ts +219 -0
- package/src/testing/memory-adapter/index.ts +388 -0
- package/src/testing/memory-adapter/migrations.ts +119 -0
- package/src/testing/memory-adapter/reset.ts +50 -0
- package/src/testing/memory-adapter/store.ts +134 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The public lifecycle functions.
|
|
3
|
+
*
|
|
4
|
+
* Scope deletion and reset are module-level rather than handle methods: they
|
|
5
|
+
* must work whether or not a handle is open on the target (FR-059), and
|
|
6
|
+
* `resetScope` in particular must never be reachable as a fallback from
|
|
7
|
+
* `openDatabase` (FR-057a).
|
|
8
|
+
*
|
|
9
|
+
* The active adapter is resolved lazily so a consuming app that only ever
|
|
10
|
+
* uses the in-memory adapter in tests never loads the native peers.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { DatabaseHandle, StorageAdapter } from '../contract/adapter';
|
|
14
|
+
import type {
|
|
15
|
+
DatabaseConfig,
|
|
16
|
+
DatabaseTargetConfig,
|
|
17
|
+
ScopeTargetConfig,
|
|
18
|
+
} from '../contract/config';
|
|
19
|
+
import type { DeletionOutcome } from '../contract/attachment';
|
|
20
|
+
|
|
21
|
+
let override: StorageAdapter | null = null;
|
|
22
|
+
let cached: StorageAdapter | null = null;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Swap the adapter. Intended for tests and for the demo app's in-memory
|
|
26
|
+
* mode; production code never calls this.
|
|
27
|
+
*/
|
|
28
|
+
export function setStorageAdapter(adapter: StorageAdapter | null): void {
|
|
29
|
+
override = adapter;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function activeAdapter(): StorageAdapter {
|
|
33
|
+
if (override) {
|
|
34
|
+
return override;
|
|
35
|
+
}
|
|
36
|
+
if (!cached) {
|
|
37
|
+
// Lazy require, matching how rn-auth and rn-camera reach their native
|
|
38
|
+
// peers: importing at module scope would pull op-sqlite into every
|
|
39
|
+
// consumer, including ones that only run the in-memory adapter.
|
|
40
|
+
const { createSqliteAdapter } = require('../sqlite/adapter') as {
|
|
41
|
+
createSqliteAdapter: () => StorageAdapter;
|
|
42
|
+
};
|
|
43
|
+
cached = createSqliteAdapter();
|
|
44
|
+
}
|
|
45
|
+
return cached;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function openDatabase(config: DatabaseConfig): Promise<DatabaseHandle> {
|
|
49
|
+
return activeAdapter().open(config);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function deleteScopeData(config: ScopeTargetConfig): Promise<DeletionOutcome> {
|
|
53
|
+
return activeAdapter().deleteScopeData(config);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function deleteDatabase(config: DatabaseTargetConfig): Promise<DeletionOutcome> {
|
|
57
|
+
return activeAdapter().deleteDatabase(config);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Destructive. Discards one scope's data and leaves it ready for a clean
|
|
62
|
+
* first-run migration. Named apart from `deleteScopeData` deliberately: one
|
|
63
|
+
* is a lifecycle operation, the other is an admission that a database is
|
|
64
|
+
* broken.
|
|
65
|
+
*/
|
|
66
|
+
export function resetScope(config: ScopeTargetConfig): Promise<DeletionOutcome> {
|
|
67
|
+
return activeAdapter().resetScope(config);
|
|
68
|
+
}
|
|
@@ -0,0 +1,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 } 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
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `Transaction` a consumer actually holds.
|
|
3
|
+
*
|
|
4
|
+
* Two rules live here rather than in either adapter, so both are held to the
|
|
5
|
+
* same standard:
|
|
6
|
+
*
|
|
7
|
+
* **Read-your-own-writes** (FR-043). Every collection reached through `tx`
|
|
8
|
+
* runs against the transaction's executor, so a read inside sees the writes
|
|
9
|
+
* made inside. A collection reached through the handle during a transaction
|
|
10
|
+
* would not, and handing one out would be a quiet correctness trap.
|
|
11
|
+
*
|
|
12
|
+
* **Terminal state is terminal** (FR-044). After a commit or an abort, using
|
|
13
|
+
* the transaction raises rather than silently writing outside it — the
|
|
14
|
+
* failure mode where a caller's cleanup code lands in the database after the
|
|
15
|
+
* rollback it was supposed to be part of.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { DiagnosticCodes } from '../contract/errors';
|
|
19
|
+
import type { StorableRecordData } from '../contract/record';
|
|
20
|
+
import type { CollectionOps, Transaction } from '../contract/transaction';
|
|
21
|
+
import { raise } from '../diagnostics/errors';
|
|
22
|
+
|
|
23
|
+
export type TransactionHost = {
|
|
24
|
+
collection<T extends StorableRecordData>(name: string): CollectionOps<T>;
|
|
25
|
+
rollback(): Promise<void>;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type ManagedTransaction = {
|
|
29
|
+
transaction: Transaction;
|
|
30
|
+
/** True once the body committed or aborted. */
|
|
31
|
+
isSettled(): boolean;
|
|
32
|
+
settle(): void;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export function createTransaction(host: TransactionHost): ManagedTransaction {
|
|
36
|
+
let settled = false;
|
|
37
|
+
|
|
38
|
+
const assertLive = (): void => {
|
|
39
|
+
if (settled) {
|
|
40
|
+
raise(DiagnosticCodes.TRANSACTION_STATE, {
|
|
41
|
+
message: 'transaction already settled',
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const transaction: Transaction = {
|
|
47
|
+
collection<T extends StorableRecordData>(name: string): CollectionOps<T> {
|
|
48
|
+
assertLive();
|
|
49
|
+
return host.collection<T>(name);
|
|
50
|
+
},
|
|
51
|
+
async abort(): Promise<void> {
|
|
52
|
+
assertLive();
|
|
53
|
+
settled = true;
|
|
54
|
+
await host.rollback();
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
transaction,
|
|
60
|
+
isSettled: () => settled,
|
|
61
|
+
settle: () => {
|
|
62
|
+
settled = true;
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Is this artifact still there?", answered without opening it.
|
|
3
|
+
*
|
|
4
|
+
* `stat()` and, for an encrypted artifact, the 22-byte header. Never a
|
|
5
|
+
* content byte and never a decryption (FR-073e, FR-107b). An availability
|
|
6
|
+
* check that decrypted would make a gallery screen showing fifty thumbnails
|
|
7
|
+
* decrypt fifty artifacts to answer a question none of them asked, and would
|
|
8
|
+
* put key material in play for an operation that does not need it.
|
|
9
|
+
*
|
|
10
|
+
* The four states are deliberately four rather than two. `missing` is data
|
|
11
|
+
* that is gone; `undecryptable` is data that exists under a key that does not
|
|
12
|
+
* open it, which is often recoverable and always a different conversation to
|
|
13
|
+
* have with the user (FR-107c).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { AttachmentAvailability } from '../contract/attachment';
|
|
17
|
+
import { fileSystem } from '../platform/fs';
|
|
18
|
+
import { HEADER_BYTES, MAGIC } from '../crypto/chunked-cipher';
|
|
19
|
+
import type { Executor } from '../sqlite/connection';
|
|
20
|
+
import { artifactPath } from './paths';
|
|
21
|
+
import { readReference, type ArtifactContext } from './store';
|
|
22
|
+
|
|
23
|
+
export async function artifactAvailability(
|
|
24
|
+
context: ArtifactContext,
|
|
25
|
+
executor: Executor,
|
|
26
|
+
id: string,
|
|
27
|
+
): Promise<AttachmentAvailability> {
|
|
28
|
+
const reference = await readReference(executor, id);
|
|
29
|
+
if (!reference) {
|
|
30
|
+
// No reference is "missing" rather than an error: the caller asked
|
|
31
|
+
// about an artifact this scope does not claim to have.
|
|
32
|
+
return { id, state: 'missing' };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const path = artifactPath(context.layout, context.scopeKey, id);
|
|
36
|
+
|
|
37
|
+
let size: number;
|
|
38
|
+
try {
|
|
39
|
+
const stat = await fileSystem().stat(path);
|
|
40
|
+
if (!stat.isFile()) {
|
|
41
|
+
return { id, state: 'inaccessible' };
|
|
42
|
+
}
|
|
43
|
+
size = stat.size;
|
|
44
|
+
} catch {
|
|
45
|
+
return { id, state: 'missing' };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (context.artifactKey(id) === null) {
|
|
49
|
+
return { id, state: 'available', size: reference.size };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (size < HEADER_BYTES) {
|
|
53
|
+
return { id, state: 'undecryptable', size: reference.size };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
const header = Buffer.from(
|
|
58
|
+
await fileSystem().read(path, HEADER_BYTES, 0, 'base64'),
|
|
59
|
+
'base64',
|
|
60
|
+
);
|
|
61
|
+
if (header.toString('ascii', 0, 4) !== MAGIC) {
|
|
62
|
+
return { id, state: 'undecryptable', size: reference.size };
|
|
63
|
+
}
|
|
64
|
+
} catch {
|
|
65
|
+
return { id, state: 'inaccessible', size: reference.size };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return { id, state: 'available', size: reference.size };
|
|
69
|
+
}
|