@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
package/package.json
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@chipmobilesdk/rn-local-db",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"license": "UNLICENSED",
|
|
6
|
+
"main": "src/index.ts",
|
|
7
|
+
"types": "src/index.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./src/index.ts",
|
|
11
|
+
"react-native": "./src/index.ts",
|
|
12
|
+
"default": "./src/index.ts"
|
|
13
|
+
},
|
|
14
|
+
"./testing": {
|
|
15
|
+
"types": "./src/testing/index.ts",
|
|
16
|
+
"react-native": "./src/testing/index.ts",
|
|
17
|
+
"default": "./src/testing/index.ts"
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"src/index.ts",
|
|
22
|
+
"src/adapter",
|
|
23
|
+
"src/artifacts",
|
|
24
|
+
"src/contract",
|
|
25
|
+
"src/crypto",
|
|
26
|
+
"src/diagnostics",
|
|
27
|
+
"src/migration",
|
|
28
|
+
"src/platform",
|
|
29
|
+
"src/query",
|
|
30
|
+
"src/schema",
|
|
31
|
+
"src/scope",
|
|
32
|
+
"src/sqlite",
|
|
33
|
+
"src/testing",
|
|
34
|
+
"README.md",
|
|
35
|
+
"CHANGELOG.md"
|
|
36
|
+
],
|
|
37
|
+
"sideEffects": false,
|
|
38
|
+
"scripts": {
|
|
39
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
40
|
+
"pack:dry-run": "npm pack --dry-run"
|
|
41
|
+
},
|
|
42
|
+
"peerDependencies": {
|
|
43
|
+
"@dr.pogodin/react-native-fs": ">=2.39.2",
|
|
44
|
+
"@op-engineering/op-sqlite": ">=17.1.0",
|
|
45
|
+
"react": ">=19",
|
|
46
|
+
"react-native": ">=0.85",
|
|
47
|
+
"react-native-quick-crypto": ">=1.0.0"
|
|
48
|
+
},
|
|
49
|
+
"peerDependenciesMeta": {
|
|
50
|
+
"react-native-quick-crypto": {
|
|
51
|
+
"optional": true
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
"engines": {
|
|
55
|
+
"node": ">=22.11.0"
|
|
56
|
+
},
|
|
57
|
+
"publishConfig": {
|
|
58
|
+
"access": "public",
|
|
59
|
+
"registry": "https://registry.npmjs.org/"
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the FR-088 budgets are actually enforced.
|
|
3
|
+
*
|
|
4
|
+
* The checks live in `query/budgets.ts` so both adapters share them; this
|
|
5
|
+
* module is the adapter-layer entry point, and exists so there is one place
|
|
6
|
+
* to answer "which budgets are hard?" — per-record size, batch size, page
|
|
7
|
+
* size, and artifact size, each with a typed error (FR-088a).
|
|
8
|
+
*
|
|
9
|
+
* The rest are soft by design. Exceeding 100,000 records in a collection is
|
|
10
|
+
* slow, not an error, and refusing the 100,001st write would destroy data an
|
|
11
|
+
* app already holds. Degraded behavior is documented in the README instead.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { Budgets } from '../contract/config';
|
|
15
|
+
import { DiagnosticCodes } from '../contract/errors';
|
|
16
|
+
import type { StorableRecordData } from '../contract/record';
|
|
17
|
+
import { raise } from '../diagnostics/errors';
|
|
18
|
+
import {
|
|
19
|
+
assertArtifactWithinBudget,
|
|
20
|
+
assertBatchWithinBudget,
|
|
21
|
+
assertRecordWithinBudget,
|
|
22
|
+
serializedSize,
|
|
23
|
+
} from '../query/budgets';
|
|
24
|
+
|
|
25
|
+
export {
|
|
26
|
+
assertArtifactWithinBudget,
|
|
27
|
+
assertBatchWithinBudget,
|
|
28
|
+
assertRecordWithinBudget,
|
|
29
|
+
serializedSize,
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Concurrently open handles.
|
|
34
|
+
*
|
|
35
|
+
* Each one holds a native connection and its write-ahead log, so an app that
|
|
36
|
+
* leaks handles runs out of file descriptors rather than slowing down. Eight
|
|
37
|
+
* is generous for the multi-identity case FR-008b describes and low enough
|
|
38
|
+
* that a leak surfaces during development instead of in the field.
|
|
39
|
+
*/
|
|
40
|
+
let openHandles = 0;
|
|
41
|
+
|
|
42
|
+
export function acquireHandleSlot(): void {
|
|
43
|
+
if (openHandles >= Budgets.MAX_OPEN_HANDLES) {
|
|
44
|
+
raise(DiagnosticCodes.OPEN_FAILED, {
|
|
45
|
+
message: 'too many database handles are open',
|
|
46
|
+
detail: { count: openHandles, limit: Budgets.MAX_OPEN_HANDLES },
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
openHandles += 1;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function releaseHandleSlot(): void {
|
|
53
|
+
openHandles = Math.max(0, openHandles - 1);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function openHandleCount(): number {
|
|
57
|
+
return openHandles;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Between tests, and after a `deleteDatabase` that invalidated every handle. */
|
|
61
|
+
export function resetHandleSlots(): void {
|
|
62
|
+
openHandles = 0;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function assertWritable(collection: string, data: StorableRecordData): void {
|
|
66
|
+
assertRecordWithinBudget(collection, data);
|
|
67
|
+
}
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Collection operations against a SQLite executor.
|
|
3
|
+
*
|
|
4
|
+
* The executor is either the connection or the transaction in flight on it,
|
|
5
|
+
* which is what gives read-your-own-writes for free (FR-043): a read inside a
|
|
6
|
+
* transaction runs on the same handle that made the writes, so it sees them,
|
|
7
|
+
* and a reader outside does not.
|
|
8
|
+
*
|
|
9
|
+
* Every semantic decision that could differ between the two adapters —
|
|
10
|
+
* which operators mean what, how nulls sort, what a cursor points at — is
|
|
11
|
+
* delegated to `query/`. This file decides only how to say it in SQL.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { DiagnosticCodes } from '../contract/errors';
|
|
15
|
+
import type { FilterNode, Query, ResultPage } from '../contract/query';
|
|
16
|
+
import type {
|
|
17
|
+
RecordInput,
|
|
18
|
+
StorableRecordData,
|
|
19
|
+
StoredRecord,
|
|
20
|
+
WriteOutcome,
|
|
21
|
+
} from '../contract/record';
|
|
22
|
+
import type { CollectionSchema } from '../contract/schema';
|
|
23
|
+
import type { CollectionOps } from '../contract/transaction';
|
|
24
|
+
import { raise } from '../diagnostics/errors';
|
|
25
|
+
import { throwIfFaulted } from '../diagnostics/faults';
|
|
26
|
+
import { decodeCursor } from '../query/cursor';
|
|
27
|
+
import { buildPage, fetchLimitFor, resolvePageSize } from '../query/paginate';
|
|
28
|
+
import { withStableTiebreaker } from '../query/sort';
|
|
29
|
+
import { validateQuery } from '../query/validate';
|
|
30
|
+
import { collectionTable, hasSoftDelete, hasTimestamps } from '../schema/declare';
|
|
31
|
+
import type { Executor } from '../sqlite/connection';
|
|
32
|
+
import type { SqlValue } from '../sqlite/native';
|
|
33
|
+
import {
|
|
34
|
+
decodeRow,
|
|
35
|
+
encodePayload,
|
|
36
|
+
insertColumns,
|
|
37
|
+
projectedColumnNames,
|
|
38
|
+
projectedValues,
|
|
39
|
+
updateAssignments,
|
|
40
|
+
} from '../sqlite/row-codec';
|
|
41
|
+
import { buildFilter, buildKeysetPredicate, buildOrderBy } from '../sqlite/sql-builder';
|
|
42
|
+
import { assertRecordWithinBudget } from './budgets';
|
|
43
|
+
|
|
44
|
+
export type SqlCollectionContext = {
|
|
45
|
+
/** Resolved per call, so a transaction can swap in its own executor. */
|
|
46
|
+
executor(): Executor;
|
|
47
|
+
schema: CollectionSchema;
|
|
48
|
+
now(): number;
|
|
49
|
+
assertUsable(): void;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const LIVE = 'deleted_at IS NULL';
|
|
53
|
+
|
|
54
|
+
function notFound(collection: string, id: string): never {
|
|
55
|
+
raise(DiagnosticCodes.RECORD_NOT_FOUND, {
|
|
56
|
+
message: 'record not found',
|
|
57
|
+
detail: { collection, recordId: id },
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function createSqlCollection<T extends StorableRecordData>(
|
|
62
|
+
context: SqlCollectionContext,
|
|
63
|
+
): CollectionOps<T> {
|
|
64
|
+
const { schema } = context;
|
|
65
|
+
const table = `"${collectionTable(schema.name)}"`;
|
|
66
|
+
|
|
67
|
+
const selectColumns = ['id', 'payload', 'created_at', 'updated_at', 'deleted_at'].join(', ');
|
|
68
|
+
|
|
69
|
+
async function rowById(id: string, includeDeleted = false): Promise<Record<string, unknown> | undefined> {
|
|
70
|
+
const result = await context.executor().execute(
|
|
71
|
+
`SELECT ${selectColumns} FROM ${table} WHERE id = ?${includeDeleted ? '' : ` AND ${LIVE}`}`,
|
|
72
|
+
[id],
|
|
73
|
+
);
|
|
74
|
+
return result.rows[0];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
async insert(record: RecordInput<T>): Promise<WriteOutcome> {
|
|
79
|
+
context.assertUsable();
|
|
80
|
+
throwIfFaulted('write');
|
|
81
|
+
assertRecordWithinBudget(schema.name, record.data);
|
|
82
|
+
|
|
83
|
+
// Checked rather than left to the primary-key constraint: the
|
|
84
|
+
// constraint would work, but inside a transaction the engine's
|
|
85
|
+
// error can arrive after other statements have run, and reporting
|
|
86
|
+
// the conflict before touching anything keeps the failure boring.
|
|
87
|
+
if (await rowById(record.id, true)) {
|
|
88
|
+
raise(DiagnosticCodes.RECORD_CONFLICT, {
|
|
89
|
+
message: 'record already exists',
|
|
90
|
+
detail: { collection: schema.name, recordId: record.id },
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const { columns, values } = insertColumns(schema, record.id, record.data, context.now());
|
|
95
|
+
await context
|
|
96
|
+
.executor()
|
|
97
|
+
.execute(
|
|
98
|
+
`INSERT INTO ${table} (${columns.map(name => `"${name}"`).join(', ')}) VALUES (${columns
|
|
99
|
+
.map(() => '?')
|
|
100
|
+
.join(', ')})`,
|
|
101
|
+
values,
|
|
102
|
+
);
|
|
103
|
+
return { operation: 'created', id: record.id };
|
|
104
|
+
},
|
|
105
|
+
|
|
106
|
+
async update(id, record): Promise<WriteOutcome> {
|
|
107
|
+
context.assertUsable();
|
|
108
|
+
throwIfFaulted('write');
|
|
109
|
+
assertRecordWithinBudget(schema.name, record.data);
|
|
110
|
+
|
|
111
|
+
const { columns, values } = updateAssignments(schema, record.data, context.now());
|
|
112
|
+
const result = await context
|
|
113
|
+
.executor()
|
|
114
|
+
.execute(
|
|
115
|
+
`UPDATE ${table} SET ${columns
|
|
116
|
+
.map(name => `"${name}" = ?`)
|
|
117
|
+
.join(', ')} WHERE id = ? AND ${LIVE}`,
|
|
118
|
+
[...values, id],
|
|
119
|
+
);
|
|
120
|
+
if (result.rowsAffected === 0) {
|
|
121
|
+
// Creating the record instead would be a silent write to an id
|
|
122
|
+
// the caller believed already existed.
|
|
123
|
+
notFound(schema.name, id);
|
|
124
|
+
}
|
|
125
|
+
return { operation: 'updated', id };
|
|
126
|
+
},
|
|
127
|
+
|
|
128
|
+
async upsert(record: RecordInput<T>): Promise<WriteOutcome> {
|
|
129
|
+
context.assertUsable();
|
|
130
|
+
throwIfFaulted('write');
|
|
131
|
+
assertRecordWithinBudget(schema.name, record.data);
|
|
132
|
+
|
|
133
|
+
const existed = (await rowById(record.id, true)) !== undefined;
|
|
134
|
+
const now = context.now();
|
|
135
|
+
|
|
136
|
+
if (existed) {
|
|
137
|
+
const assignments = ['payload = ?'];
|
|
138
|
+
const values: SqlValue[] = [encodePayload(record.data)];
|
|
139
|
+
if (hasTimestamps(schema)) {
|
|
140
|
+
assignments.push('updated_at = ?');
|
|
141
|
+
values.push(now);
|
|
142
|
+
}
|
|
143
|
+
if (hasSoftDelete(schema)) {
|
|
144
|
+
// An upsert over a soft-deleted record brings it back: the
|
|
145
|
+
// caller asked for this id to hold this data.
|
|
146
|
+
assignments.push('deleted_at = NULL');
|
|
147
|
+
}
|
|
148
|
+
const projected = projectedValues(schema, record.data);
|
|
149
|
+
for (const [index, name] of projectedColumnNames(schema).entries()) {
|
|
150
|
+
assignments.push(`"${name}" = ?`);
|
|
151
|
+
values.push(projected[index]!);
|
|
152
|
+
}
|
|
153
|
+
await context
|
|
154
|
+
.executor()
|
|
155
|
+
.execute(`UPDATE ${table} SET ${assignments.join(', ')} WHERE id = ?`, [
|
|
156
|
+
...values,
|
|
157
|
+
record.id,
|
|
158
|
+
]);
|
|
159
|
+
return { operation: 'updated', id: record.id };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const { columns, values } = insertColumns(schema, record.id, record.data, now);
|
|
163
|
+
await context
|
|
164
|
+
.executor()
|
|
165
|
+
.execute(
|
|
166
|
+
`INSERT INTO ${table} (${columns.map(name => `"${name}"`).join(', ')}) VALUES (${columns
|
|
167
|
+
.map(() => '?')
|
|
168
|
+
.join(', ')})`,
|
|
169
|
+
values,
|
|
170
|
+
);
|
|
171
|
+
return { operation: 'created', id: record.id };
|
|
172
|
+
},
|
|
173
|
+
|
|
174
|
+
async get(id: string): Promise<StoredRecord<T>> {
|
|
175
|
+
context.assertUsable();
|
|
176
|
+
throwIfFaulted('read');
|
|
177
|
+
const row = await rowById(id);
|
|
178
|
+
if (!row) {
|
|
179
|
+
notFound(schema.name, id);
|
|
180
|
+
}
|
|
181
|
+
return decodeRow<T>(schema, row);
|
|
182
|
+
},
|
|
183
|
+
|
|
184
|
+
async find(id: string): Promise<StoredRecord<T> | null> {
|
|
185
|
+
context.assertUsable();
|
|
186
|
+
throwIfFaulted('read');
|
|
187
|
+
const row = await rowById(id);
|
|
188
|
+
return row ? decodeRow<T>(schema, row) : null;
|
|
189
|
+
},
|
|
190
|
+
|
|
191
|
+
async delete(id: string): Promise<WriteOutcome> {
|
|
192
|
+
context.assertUsable();
|
|
193
|
+
throwIfFaulted('write');
|
|
194
|
+
const result = await context.executor().execute(`DELETE FROM ${table} WHERE id = ?`, [id]);
|
|
195
|
+
if (result.rowsAffected === 0) {
|
|
196
|
+
notFound(schema.name, id);
|
|
197
|
+
}
|
|
198
|
+
return { operation: 'deleted', id };
|
|
199
|
+
},
|
|
200
|
+
|
|
201
|
+
async softDelete(id: string): Promise<WriteOutcome> {
|
|
202
|
+
context.assertUsable();
|
|
203
|
+
throwIfFaulted('write');
|
|
204
|
+
if (!hasSoftDelete(schema)) {
|
|
205
|
+
raise(DiagnosticCodes.CONFIG_INVALID, {
|
|
206
|
+
message: 'collection does not declare soft delete',
|
|
207
|
+
detail: { collection: schema.name },
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
const result = await context
|
|
211
|
+
.executor()
|
|
212
|
+
.execute(`UPDATE ${table} SET deleted_at = ? WHERE id = ? AND ${LIVE}`, [
|
|
213
|
+
context.now(),
|
|
214
|
+
id,
|
|
215
|
+
]);
|
|
216
|
+
if (result.rowsAffected === 0) {
|
|
217
|
+
notFound(schema.name, id);
|
|
218
|
+
}
|
|
219
|
+
return { operation: 'deleted', id };
|
|
220
|
+
},
|
|
221
|
+
|
|
222
|
+
async restore(id: string): Promise<WriteOutcome> {
|
|
223
|
+
context.assertUsable();
|
|
224
|
+
throwIfFaulted('write');
|
|
225
|
+
if (!hasSoftDelete(schema)) {
|
|
226
|
+
raise(DiagnosticCodes.CONFIG_INVALID, {
|
|
227
|
+
message: 'collection does not declare soft delete',
|
|
228
|
+
detail: { collection: schema.name },
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
const result = await context
|
|
232
|
+
.executor()
|
|
233
|
+
.execute(`UPDATE ${table} SET deleted_at = NULL WHERE id = ? AND deleted_at IS NOT NULL`, [
|
|
234
|
+
id,
|
|
235
|
+
]);
|
|
236
|
+
if (result.rowsAffected === 0) {
|
|
237
|
+
notFound(schema.name, id);
|
|
238
|
+
}
|
|
239
|
+
return { operation: 'restored', id };
|
|
240
|
+
},
|
|
241
|
+
|
|
242
|
+
async list(query?: Query): Promise<ResultPage<T>> {
|
|
243
|
+
context.assertUsable();
|
|
244
|
+
throwIfFaulted('read');
|
|
245
|
+
validateQuery(schema, query);
|
|
246
|
+
|
|
247
|
+
const sort = withStableTiebreaker(query?.sort);
|
|
248
|
+
const size = resolvePageSize(query?.page);
|
|
249
|
+
const filter = buildFilter(query?.filter);
|
|
250
|
+
|
|
251
|
+
const clauses = [filter.sql];
|
|
252
|
+
const params: SqlValue[] = [...filter.params];
|
|
253
|
+
|
|
254
|
+
if (query?.includeSoftDeleted !== true) {
|
|
255
|
+
clauses.push(LIVE);
|
|
256
|
+
}
|
|
257
|
+
if (query?.page?.cursor) {
|
|
258
|
+
const keyset = buildKeysetPredicate(sort, decodeCursor(query.page.cursor, sort));
|
|
259
|
+
clauses.push(keyset.sql);
|
|
260
|
+
params.push(...keyset.params);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// One row past the page, so `hasMore` is answered without a second
|
|
264
|
+
// query that could disagree with the first under a concurrent write.
|
|
265
|
+
const limit = fetchLimitFor(size);
|
|
266
|
+
const result = await context
|
|
267
|
+
.executor()
|
|
268
|
+
.execute(
|
|
269
|
+
`SELECT ${selectColumns} FROM ${table} WHERE ${clauses.join(
|
|
270
|
+
' AND ',
|
|
271
|
+
)} ORDER BY ${buildOrderBy(sort)} LIMIT ?`,
|
|
272
|
+
[...params, limit],
|
|
273
|
+
);
|
|
274
|
+
|
|
275
|
+
return buildPage(
|
|
276
|
+
result.rows.map(row => decodeRow<T>(schema, row)),
|
|
277
|
+
sort,
|
|
278
|
+
size,
|
|
279
|
+
);
|
|
280
|
+
},
|
|
281
|
+
|
|
282
|
+
async count(filter?: FilterNode): Promise<number> {
|
|
283
|
+
context.assertUsable();
|
|
284
|
+
throwIfFaulted('read');
|
|
285
|
+
if (filter) {
|
|
286
|
+
validateQuery(schema, { filter });
|
|
287
|
+
}
|
|
288
|
+
const built = buildFilter(filter);
|
|
289
|
+
const result = await context
|
|
290
|
+
.executor()
|
|
291
|
+
.execute(
|
|
292
|
+
`SELECT count(*) AS total FROM ${table} WHERE ${built.sql} AND ${LIVE}`,
|
|
293
|
+
built.params,
|
|
294
|
+
);
|
|
295
|
+
return Number(result.rows[0]?.total ?? 0);
|
|
296
|
+
},
|
|
297
|
+
};
|
|
298
|
+
}
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deletion, and the ordering that makes an interrupted one visible.
|
|
3
|
+
*
|
|
4
|
+
* These are module-level functions rather than handle methods because they
|
|
5
|
+
* must work whether or not a handle is open on the target (FR-059) — a
|
|
6
|
+
* sign-out that already closed the handle still has to be able to delete, and
|
|
7
|
+
* an app recovering from a crash has no handle to call a method on.
|
|
8
|
+
*
|
|
9
|
+
* The order is: **mark `deleting` first, clear the catalog row last**
|
|
10
|
+
* (FR-061, FR-063). A crash at any point in between leaves the flag set, so
|
|
11
|
+
* the next open reports a scope mid-deletion rather than presenting it as
|
|
12
|
+
* complete. Clearing the row first would be the natural way to write this and
|
|
13
|
+
* would produce exactly the failure FR-063 exists to prevent: an
|
|
14
|
+
* interruption that looks like a success, leaving a user's data on disk after
|
|
15
|
+
* they asked for it to be gone.
|
|
16
|
+
*
|
|
17
|
+
* Nothing here ever runs on its own. No expiry, no sweep, no deletion on
|
|
18
|
+
* close or on app upgrade (FR-058) — the package deletes when asked, where
|
|
19
|
+
* asked, and not otherwise.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import type { DeletionOutcome } from '../contract/attachment';
|
|
23
|
+
import type { DatabaseTargetConfig, ScopeTargetConfig } from '../contract/config';
|
|
24
|
+
import type { ResolvedScope } from '../contract/scope';
|
|
25
|
+
import { fileSystem, removeIfPresent } from '../platform/fs';
|
|
26
|
+
import { layoutFor, type StorageLayout } from '../platform/location';
|
|
27
|
+
import { artifactsDirectory } from '../artifacts/paths';
|
|
28
|
+
import { acquireKey } from '../crypto/key-provider';
|
|
29
|
+
import { byteOrder } from '../query/compare';
|
|
30
|
+
import { resolveScope } from '../scope/validate';
|
|
31
|
+
import { clearScope, ensureCatalog, listEntries, markDeleting, readEntry } from '../sqlite/catalog';
|
|
32
|
+
import { openConnection, type Connection } from '../sqlite/connection';
|
|
33
|
+
import { num, text } from '../sqlite/values';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Scopes deleted beneath a handle that is still open.
|
|
37
|
+
*
|
|
38
|
+
* FR-059a wants the handle to report `SCOPE_DELETED` rather than keep
|
|
39
|
+
* answering from a database that no longer exists — and wants the deletion to
|
|
40
|
+
* proceed rather than wait for the handle to be closed. A shared set is the
|
|
41
|
+
* whole mechanism: the deleter records, the handle checks.
|
|
42
|
+
*/
|
|
43
|
+
const invalidated = new Set<string>();
|
|
44
|
+
|
|
45
|
+
function invalidationKey(name: string, scopeKey: string): string {
|
|
46
|
+
return `${name}:${scopeKey}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function markScopeInvalidated(name: string, scopeKey: string): void {
|
|
50
|
+
invalidated.add(invalidationKey(name, scopeKey));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function clearScopeInvalidation(name: string, scopeKey: string): void {
|
|
54
|
+
invalidated.delete(invalidationKey(name, scopeKey));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function isScopeInvalidated(name: string, scopeKey: string): boolean {
|
|
58
|
+
return invalidated.has(invalidationKey(name, scopeKey));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function clearAllInvalidations(): void {
|
|
62
|
+
invalidated.clear();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Deleting a scope that holds nothing is success, not an error (FR-062). */
|
|
66
|
+
export function emptyOutcome(scope?: ResolvedScope): DeletionOutcome {
|
|
67
|
+
return {
|
|
68
|
+
...(scope ? { scopeKey: scope.key } : {}),
|
|
69
|
+
removedRecords: 0,
|
|
70
|
+
removedArtifacts: [],
|
|
71
|
+
orphanedReferences: [],
|
|
72
|
+
failedRemovals: [],
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* How many records the scope held, best effort.
|
|
78
|
+
*
|
|
79
|
+
* Best effort because an encrypted scope whose caller supplied no key cannot
|
|
80
|
+
* be opened to be counted — and refusing to delete it on that basis would be
|
|
81
|
+
* the worst possible answer to a deletion request. The files come off disk
|
|
82
|
+
* either way; only the number in the report is unavailable.
|
|
83
|
+
*/
|
|
84
|
+
async function countRecords(
|
|
85
|
+
layout: StorageLayout,
|
|
86
|
+
scopeKey: string,
|
|
87
|
+
encryptionKey: string | undefined,
|
|
88
|
+
): Promise<number> {
|
|
89
|
+
let connection: Connection | null = null;
|
|
90
|
+
try {
|
|
91
|
+
connection = await openConnection({
|
|
92
|
+
path: layout.scopeDataPath(scopeKey),
|
|
93
|
+
...(encryptionKey ? { encryptionKey } : {}),
|
|
94
|
+
});
|
|
95
|
+
const tables = await connection.execute(
|
|
96
|
+
"SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE 'c!_%' ESCAPE '!'",
|
|
97
|
+
);
|
|
98
|
+
let total = 0;
|
|
99
|
+
for (const row of tables.rows) {
|
|
100
|
+
const table = text(row.name);
|
|
101
|
+
const counted = await connection.execute(`SELECT count(*) AS total FROM "${table}"`);
|
|
102
|
+
total += num(counted.rows[0]?.total ?? 0);
|
|
103
|
+
}
|
|
104
|
+
return total;
|
|
105
|
+
} catch {
|
|
106
|
+
return 0;
|
|
107
|
+
} finally {
|
|
108
|
+
await connection?.close();
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function artifactIdsOnDisk(layout: StorageLayout, scopeKey: string): Promise<string[]> {
|
|
113
|
+
try {
|
|
114
|
+
return (await fileSystem().readdir(artifactsDirectory(layout, scopeKey))).sort(byteOrder);
|
|
115
|
+
} catch {
|
|
116
|
+
return [];
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The one implementation behind `deleteScopeData` and `resetScope`.
|
|
122
|
+
*
|
|
123
|
+
* They do the same thing to disk and mean entirely different things to the
|
|
124
|
+
* caller: one is a lifecycle operation an app performs on request, the other
|
|
125
|
+
* is an admission that a database is broken and is being discarded to recover
|
|
126
|
+
* from it (FR-057a). Keeping them as two exported names with two docstrings
|
|
127
|
+
* is what stops the destructive one from being reached by accident — there is
|
|
128
|
+
* no path from `openDatabase` to either.
|
|
129
|
+
*/
|
|
130
|
+
async function removeScope(config: ScopeTargetConfig): Promise<DeletionOutcome> {
|
|
131
|
+
const scope = resolveScope(config.scope);
|
|
132
|
+
const layout = layoutFor(config.name);
|
|
133
|
+
const catalog = await openConnection({ path: layout.catalogPath });
|
|
134
|
+
|
|
135
|
+
try {
|
|
136
|
+
await ensureCatalog(catalog);
|
|
137
|
+
const entry = await readEntry(catalog, scope.key);
|
|
138
|
+
|
|
139
|
+
// Mark first. Everything after this point is recoverable-in-principle;
|
|
140
|
+
// this flag is what makes an interruption visible rather than silent.
|
|
141
|
+
if (entry) {
|
|
142
|
+
await markDeleting(catalog, scope.key);
|
|
143
|
+
}
|
|
144
|
+
markScopeInvalidated(config.name, scope.key);
|
|
145
|
+
|
|
146
|
+
const removedArtifacts = await artifactIdsOnDisk(layout, scope.key);
|
|
147
|
+
|
|
148
|
+
// The key is needed to count what is being removed, not to remove it.
|
|
149
|
+
// It is released the moment the count is done, whatever happened.
|
|
150
|
+
const keyHandle = config.encryption?.enabled
|
|
151
|
+
? await acquireKey(config.encryption, scope)
|
|
152
|
+
: null;
|
|
153
|
+
let removedRecords: number;
|
|
154
|
+
try {
|
|
155
|
+
removedRecords = await countRecords(layout, scope.key, keyHandle?.value());
|
|
156
|
+
} finally {
|
|
157
|
+
keyHandle?.release();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const failedRemovals: string[] = [];
|
|
161
|
+
if (!(await removeIfPresent(layout.scopePath(scope.key))) && entry) {
|
|
162
|
+
// The directory was there and would not go. Reporting it is the
|
|
163
|
+
// point: an app that told the user "deleted" would be wrong.
|
|
164
|
+
const stillThere = await fileSystem()
|
|
165
|
+
.exists(layout.scopePath(scope.key))
|
|
166
|
+
.catch(() => false);
|
|
167
|
+
if (stillThere) {
|
|
168
|
+
failedRemovals.push(scope.key);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (failedRemovals.length === 0) {
|
|
173
|
+
// Cleared last, on purpose.
|
|
174
|
+
await clearScope(catalog, scope.key);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
scopeKey: scope.key,
|
|
179
|
+
removedRecords,
|
|
180
|
+
removedArtifacts,
|
|
181
|
+
orphanedReferences: [],
|
|
182
|
+
failedRemovals,
|
|
183
|
+
};
|
|
184
|
+
} finally {
|
|
185
|
+
await catalog.close();
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Remove one scope's data. Every collection in that scope, and nothing
|
|
191
|
+
* outside it (FR-059, SC-012).
|
|
192
|
+
*/
|
|
193
|
+
export function deleteScopeData(config: ScopeTargetConfig): Promise<DeletionOutcome> {
|
|
194
|
+
return removeScope(config);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Discard one scope and leave it ready for a clean first-run migration
|
|
199
|
+
* (FR-057a).
|
|
200
|
+
*
|
|
201
|
+
* Destructive, separately named, and never reachable from `openDatabase`. An
|
|
202
|
+
* automatic fallback here would turn a bad migration shipped to production
|
|
203
|
+
* into silent data loss for every user who launched the app.
|
|
204
|
+
*/
|
|
205
|
+
export function resetScope(config: ScopeTargetConfig): Promise<DeletionOutcome> {
|
|
206
|
+
return removeScope(config);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Remove the whole database directory: catalog, every scope, every artifact. */
|
|
210
|
+
export async function deleteDatabase(config: DatabaseTargetConfig): Promise<DeletionOutcome> {
|
|
211
|
+
const layout = layoutFor(config.name);
|
|
212
|
+
|
|
213
|
+
let scopeKeys: string[] = [];
|
|
214
|
+
try {
|
|
215
|
+
const catalog = await openConnection({ path: layout.catalogPath });
|
|
216
|
+
try {
|
|
217
|
+
await ensureCatalog(catalog);
|
|
218
|
+
scopeKeys = (await listEntries(catalog)).map(entry => entry.scopeKey);
|
|
219
|
+
} finally {
|
|
220
|
+
await catalog.close();
|
|
221
|
+
}
|
|
222
|
+
} catch {
|
|
223
|
+
// No catalog means nothing was ever registered here. The directory
|
|
224
|
+
// removal below is still the right thing to attempt.
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
for (const scopeKey of scopeKeys) {
|
|
228
|
+
markScopeInvalidated(config.name, scopeKey);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const removed = await removeIfPresent(layout.root);
|
|
232
|
+
return {
|
|
233
|
+
removedRecords: 0,
|
|
234
|
+
removedArtifacts: [],
|
|
235
|
+
orphanedReferences: [],
|
|
236
|
+
failedRemovals: removed || scopeKeys.length === 0 ? [] : scopeKeys,
|
|
237
|
+
};
|
|
238
|
+
}
|