@chipmobilesdk/rn-local-db 0.2.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +122 -102
- package/README.md +728 -728
- package/package.json +59 -62
- package/skills/sdk-rn-local-db/SKILL.md +0 -1
- package/src/adapter/budgets.ts +67 -67
- package/src/adapter/collection.ts +303 -298
- package/src/adapter/deletion.ts +252 -252
- package/src/adapter/index.ts +64 -68
- package/src/adapter/lifecycle.ts +517 -520
- package/src/adapter/transaction.ts +71 -71
- package/src/artifacts/availability.ts +67 -67
- package/src/artifacts/cascade.ts +114 -114
- package/src/artifacts/paths.ts +29 -29
- package/src/artifacts/reconcile.ts +63 -63
- package/src/artifacts/store.ts +334 -345
- package/src/contract/adapter.ts +76 -78
- package/src/contract/attachment.ts +75 -78
- package/src/contract/backup.ts +28 -28
- package/src/contract/config.ts +54 -54
- package/src/contract/encryption.ts +34 -34
- package/src/contract/errors.ts +76 -76
- package/src/contract/migration.ts +42 -42
- package/src/contract/query.ts +81 -81
- package/src/contract/record.ts +46 -51
- package/src/contract/schema.ts +36 -36
- package/src/contract/scope.ts +36 -36
- package/src/contract/transaction.ts +52 -54
- package/src/crypto/chunked-cipher.ts +153 -150
- package/src/crypto/derive.ts +64 -64
- package/src/crypto/key-provider.ts +78 -78
- package/src/crypto/provider.ts +81 -81
- package/src/diagnostics/dev-log.ts +112 -108
- package/src/diagnostics/errors.ts +62 -66
- package/src/diagnostics/faults.ts +58 -64
- package/src/diagnostics/map-native.ts +108 -106
- package/src/diagnostics/redact.ts +137 -141
- package/src/index.ts +80 -110
- package/src/migration/executor.ts +132 -136
- package/src/migration/ledger.ts +95 -97
- package/src/migration/validate-chain.ts +65 -65
- package/src/platform/backup-android-verify.ts +88 -88
- package/src/platform/backup-ios.ts +48 -48
- package/src/platform/bytes.ts +144 -144
- package/src/platform/fs.ts +89 -89
- package/src/platform/location.ts +135 -135
- package/src/query/budgets.ts +54 -54
- package/src/query/compare.ts +91 -91
- package/src/query/cursor.ts +100 -100
- package/src/query/filter.ts +82 -82
- package/src/query/paginate.ts +51 -51
- package/src/query/sort.ts +55 -56
- package/src/query/validate.ts +171 -171
- package/src/schema/declare.ts +42 -45
- package/src/schema/validate-config.ts +158 -158
- package/src/scope/hash.ts +131 -134
- package/src/scope/normalize.ts +83 -90
- package/src/scope/validate.ts +83 -83
- package/src/sqlite/adapter.ts +26 -26
- package/src/sqlite/batch.ts +242 -242
- package/src/sqlite/catalog.ts +105 -108
- package/src/sqlite/connection.ts +227 -227
- package/src/sqlite/index-coverage.ts +54 -56
- package/src/sqlite/native.ts +83 -83
- package/src/sqlite/row-codec.ts +217 -221
- package/src/sqlite/sql-builder.ts +244 -244
- package/src/sqlite/transaction-queue.ts +87 -87
- package/src/sqlite/transactions.ts +33 -33
- package/src/sqlite/values.ts +24 -24
- package/src/testing/conformance/attachments.ts +138 -136
- package/src/testing/conformance/encryption.ts +150 -150
- package/src/testing/conformance/index.ts +161 -161
- package/src/testing/conformance/lifecycle.ts +99 -99
- package/src/testing/conformance/migrations.ts +177 -177
- package/src/testing/conformance/queries.ts +204 -167
- package/src/testing/conformance/records.ts +169 -169
- package/src/testing/conformance/scopes.ts +86 -88
- package/src/testing/conformance/transactions.ts +149 -149
- package/src/testing/faults.ts +21 -21
- package/src/testing/index.ts +17 -21
- package/src/testing/memory-adapter/collection.ts +214 -219
- package/src/testing/memory-adapter/index.ts +510 -492
- package/src/testing/memory-adapter/migrations.ts +117 -119
- package/src/testing/memory-adapter/reset.ts +50 -50
- package/src/testing/memory-adapter/store.ts +128 -134
|
@@ -1,298 +1,303 @@
|
|
|
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(
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
.
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
.
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
const
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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(
|
|
70
|
+
id: string,
|
|
71
|
+
includeDeleted = false,
|
|
72
|
+
): Promise<Record<string, unknown> | undefined> {
|
|
73
|
+
const result = await context
|
|
74
|
+
.executor()
|
|
75
|
+
.execute(
|
|
76
|
+
`SELECT ${selectColumns} FROM ${table} WHERE id = ?${includeDeleted ? '' : ` AND ${LIVE}`}`,
|
|
77
|
+
[id],
|
|
78
|
+
);
|
|
79
|
+
return result.rows[0];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
async insert(record: RecordInput<T>): Promise<WriteOutcome> {
|
|
84
|
+
context.assertUsable();
|
|
85
|
+
throwIfFaulted('write');
|
|
86
|
+
assertRecordWithinBudget(schema.name, record.data);
|
|
87
|
+
|
|
88
|
+
// Checked rather than left to the primary-key constraint: the
|
|
89
|
+
// constraint would work, but inside a transaction the engine's
|
|
90
|
+
// error can arrive after other statements have run, and reporting
|
|
91
|
+
// the conflict before touching anything keeps the failure boring.
|
|
92
|
+
if (await rowById(record.id, true)) {
|
|
93
|
+
raise(DiagnosticCodes.RECORD_CONFLICT, {
|
|
94
|
+
message: 'record already exists',
|
|
95
|
+
detail: { collection: schema.name, recordId: record.id },
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const { columns, values } = insertColumns(schema, record.id, record.data, context.now());
|
|
100
|
+
await context
|
|
101
|
+
.executor()
|
|
102
|
+
.execute(
|
|
103
|
+
`INSERT INTO ${table} (${columns.map(name => `"${name}"`).join(', ')}) VALUES (${columns
|
|
104
|
+
.map(() => '?')
|
|
105
|
+
.join(', ')})`,
|
|
106
|
+
values,
|
|
107
|
+
);
|
|
108
|
+
return { operation: 'created', id: record.id };
|
|
109
|
+
},
|
|
110
|
+
|
|
111
|
+
async update(id, record): Promise<WriteOutcome> {
|
|
112
|
+
context.assertUsable();
|
|
113
|
+
throwIfFaulted('write');
|
|
114
|
+
assertRecordWithinBudget(schema.name, record.data);
|
|
115
|
+
|
|
116
|
+
const { columns, values } = updateAssignments(schema, record.data, context.now());
|
|
117
|
+
const result = await context
|
|
118
|
+
.executor()
|
|
119
|
+
.execute(
|
|
120
|
+
`UPDATE ${table} SET ${columns
|
|
121
|
+
.map(name => `"${name}" = ?`)
|
|
122
|
+
.join(', ')} WHERE id = ? AND ${LIVE}`,
|
|
123
|
+
[...values, id],
|
|
124
|
+
);
|
|
125
|
+
if (result.rowsAffected === 0) {
|
|
126
|
+
// Creating the record instead would be a silent write to an id
|
|
127
|
+
// the caller believed already existed.
|
|
128
|
+
notFound(schema.name, id);
|
|
129
|
+
}
|
|
130
|
+
return { operation: 'updated', id };
|
|
131
|
+
},
|
|
132
|
+
|
|
133
|
+
async upsert(record: RecordInput<T>): Promise<WriteOutcome> {
|
|
134
|
+
context.assertUsable();
|
|
135
|
+
throwIfFaulted('write');
|
|
136
|
+
assertRecordWithinBudget(schema.name, record.data);
|
|
137
|
+
|
|
138
|
+
const existed = (await rowById(record.id, true)) !== undefined;
|
|
139
|
+
const now = context.now();
|
|
140
|
+
|
|
141
|
+
if (existed) {
|
|
142
|
+
const assignments = ['payload = ?'];
|
|
143
|
+
const values: SqlValue[] = [encodePayload(record.data)];
|
|
144
|
+
if (hasTimestamps(schema)) {
|
|
145
|
+
assignments.push('updated_at = ?');
|
|
146
|
+
values.push(now);
|
|
147
|
+
}
|
|
148
|
+
if (hasSoftDelete(schema)) {
|
|
149
|
+
// An upsert over a soft-deleted record brings it back: the
|
|
150
|
+
// caller asked for this id to hold this data.
|
|
151
|
+
assignments.push('deleted_at = NULL');
|
|
152
|
+
}
|
|
153
|
+
const projected = projectedValues(schema, record.data);
|
|
154
|
+
for (const [index, name] of projectedColumnNames(schema).entries()) {
|
|
155
|
+
assignments.push(`"${name}" = ?`);
|
|
156
|
+
values.push(projected[index]!);
|
|
157
|
+
}
|
|
158
|
+
await context
|
|
159
|
+
.executor()
|
|
160
|
+
.execute(`UPDATE ${table} SET ${assignments.join(', ')} WHERE id = ?`, [
|
|
161
|
+
...values,
|
|
162
|
+
record.id,
|
|
163
|
+
]);
|
|
164
|
+
return { operation: 'updated', id: record.id };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const { columns, values } = insertColumns(schema, record.id, record.data, now);
|
|
168
|
+
await context
|
|
169
|
+
.executor()
|
|
170
|
+
.execute(
|
|
171
|
+
`INSERT INTO ${table} (${columns.map(name => `"${name}"`).join(', ')}) VALUES (${columns
|
|
172
|
+
.map(() => '?')
|
|
173
|
+
.join(', ')})`,
|
|
174
|
+
values,
|
|
175
|
+
);
|
|
176
|
+
return { operation: 'created', id: record.id };
|
|
177
|
+
},
|
|
178
|
+
|
|
179
|
+
async get(id: string): Promise<StoredRecord<T>> {
|
|
180
|
+
context.assertUsable();
|
|
181
|
+
throwIfFaulted('read');
|
|
182
|
+
const row = await rowById(id);
|
|
183
|
+
if (!row) {
|
|
184
|
+
notFound(schema.name, id);
|
|
185
|
+
}
|
|
186
|
+
return decodeRow<T>(schema, row);
|
|
187
|
+
},
|
|
188
|
+
|
|
189
|
+
async find(id: string): Promise<StoredRecord<T> | null> {
|
|
190
|
+
context.assertUsable();
|
|
191
|
+
throwIfFaulted('read');
|
|
192
|
+
const row = await rowById(id);
|
|
193
|
+
return row ? decodeRow<T>(schema, row) : null;
|
|
194
|
+
},
|
|
195
|
+
|
|
196
|
+
async delete(id: string): Promise<WriteOutcome> {
|
|
197
|
+
context.assertUsable();
|
|
198
|
+
throwIfFaulted('write');
|
|
199
|
+
const result = await context.executor().execute(`DELETE FROM ${table} WHERE id = ?`, [id]);
|
|
200
|
+
if (result.rowsAffected === 0) {
|
|
201
|
+
notFound(schema.name, id);
|
|
202
|
+
}
|
|
203
|
+
return { operation: 'deleted', id };
|
|
204
|
+
},
|
|
205
|
+
|
|
206
|
+
async softDelete(id: string): Promise<WriteOutcome> {
|
|
207
|
+
context.assertUsable();
|
|
208
|
+
throwIfFaulted('write');
|
|
209
|
+
if (!hasSoftDelete(schema)) {
|
|
210
|
+
raise(DiagnosticCodes.CONFIG_INVALID, {
|
|
211
|
+
message: 'collection does not declare soft delete',
|
|
212
|
+
detail: { collection: schema.name },
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
const result = await context
|
|
216
|
+
.executor()
|
|
217
|
+
.execute(`UPDATE ${table} SET deleted_at = ? WHERE id = ? AND ${LIVE}`, [
|
|
218
|
+
context.now(),
|
|
219
|
+
id,
|
|
220
|
+
]);
|
|
221
|
+
if (result.rowsAffected === 0) {
|
|
222
|
+
notFound(schema.name, id);
|
|
223
|
+
}
|
|
224
|
+
return { operation: 'deleted', id };
|
|
225
|
+
},
|
|
226
|
+
|
|
227
|
+
async restore(id: string): Promise<WriteOutcome> {
|
|
228
|
+
context.assertUsable();
|
|
229
|
+
throwIfFaulted('write');
|
|
230
|
+
if (!hasSoftDelete(schema)) {
|
|
231
|
+
raise(DiagnosticCodes.CONFIG_INVALID, {
|
|
232
|
+
message: 'collection does not declare soft delete',
|
|
233
|
+
detail: { collection: schema.name },
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
const result = await context
|
|
237
|
+
.executor()
|
|
238
|
+
.execute(`UPDATE ${table} SET deleted_at = NULL WHERE id = ? AND deleted_at IS NOT NULL`, [
|
|
239
|
+
id,
|
|
240
|
+
]);
|
|
241
|
+
if (result.rowsAffected === 0) {
|
|
242
|
+
notFound(schema.name, id);
|
|
243
|
+
}
|
|
244
|
+
return { operation: 'restored', id };
|
|
245
|
+
},
|
|
246
|
+
|
|
247
|
+
async list(query?: Query): Promise<ResultPage<T>> {
|
|
248
|
+
context.assertUsable();
|
|
249
|
+
throwIfFaulted('read');
|
|
250
|
+
validateQuery(schema, query);
|
|
251
|
+
|
|
252
|
+
const sort = withStableTiebreaker(query?.sort);
|
|
253
|
+
const size = resolvePageSize(query?.page);
|
|
254
|
+
const filter = buildFilter(query?.filter);
|
|
255
|
+
|
|
256
|
+
const clauses = [filter.sql];
|
|
257
|
+
const params: SqlValue[] = [...filter.params];
|
|
258
|
+
|
|
259
|
+
if (query?.includeSoftDeleted !== true) {
|
|
260
|
+
clauses.push(LIVE);
|
|
261
|
+
}
|
|
262
|
+
if (query?.page?.cursor) {
|
|
263
|
+
const keyset = buildKeysetPredicate(sort, decodeCursor(query.page.cursor, sort));
|
|
264
|
+
clauses.push(keyset.sql);
|
|
265
|
+
params.push(...keyset.params);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// One row past the page, so `hasMore` is answered without a second
|
|
269
|
+
// query that could disagree with the first under a concurrent write.
|
|
270
|
+
const limit = fetchLimitFor(size);
|
|
271
|
+
const result = await context
|
|
272
|
+
.executor()
|
|
273
|
+
.execute(
|
|
274
|
+
`SELECT ${selectColumns} FROM ${table} WHERE ${clauses.join(
|
|
275
|
+
' AND ',
|
|
276
|
+
)} ORDER BY ${buildOrderBy(sort)} LIMIT ?`,
|
|
277
|
+
[...params, limit],
|
|
278
|
+
);
|
|
279
|
+
|
|
280
|
+
return buildPage(
|
|
281
|
+
result.rows.map(row => decodeRow<T>(schema, row)),
|
|
282
|
+
sort,
|
|
283
|
+
size,
|
|
284
|
+
);
|
|
285
|
+
},
|
|
286
|
+
|
|
287
|
+
async count(filter?: FilterNode): Promise<number> {
|
|
288
|
+
context.assertUsable();
|
|
289
|
+
throwIfFaulted('read');
|
|
290
|
+
if (filter) {
|
|
291
|
+
validateQuery(schema, { filter });
|
|
292
|
+
}
|
|
293
|
+
const built = buildFilter(filter);
|
|
294
|
+
const result = await context
|
|
295
|
+
.executor()
|
|
296
|
+
.execute(
|
|
297
|
+
`SELECT count(*) AS total FROM ${table} WHERE ${built.sql} AND ${LIVE}`,
|
|
298
|
+
built.params,
|
|
299
|
+
);
|
|
300
|
+
return Number(result.rows[0]?.total ?? 0);
|
|
301
|
+
},
|
|
302
|
+
};
|
|
303
|
+
}
|