@chipmobilesdk/rn-local-db 0.1.2 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/CHANGELOG.md +122 -52
  2. package/README.md +663 -661
  3. package/package.json +59 -62
  4. package/skills/sdk-rn-local-db/SKILL.md +0 -1
  5. package/src/adapter/budgets.ts +67 -67
  6. package/src/adapter/collection.ts +303 -298
  7. package/src/adapter/deletion.ts +252 -238
  8. package/src/adapter/index.ts +64 -68
  9. package/src/adapter/lifecycle.ts +517 -444
  10. package/src/adapter/transaction.ts +71 -65
  11. package/src/artifacts/availability.ts +67 -69
  12. package/src/artifacts/cascade.ts +114 -78
  13. package/src/artifacts/paths.ts +29 -29
  14. package/src/artifacts/reconcile.ts +63 -63
  15. package/src/artifacts/store.ts +334 -323
  16. package/src/contract/adapter.ts +76 -78
  17. package/src/contract/attachment.ts +75 -72
  18. package/src/contract/backup.ts +28 -28
  19. package/src/contract/config.ts +54 -54
  20. package/src/contract/encryption.ts +34 -34
  21. package/src/contract/errors.ts +76 -76
  22. package/src/contract/migration.ts +42 -40
  23. package/src/contract/query.ts +81 -81
  24. package/src/contract/record.ts +46 -42
  25. package/src/contract/schema.ts +36 -36
  26. package/src/contract/scope.ts +36 -36
  27. package/src/contract/transaction.ts +52 -46
  28. package/src/crypto/chunked-cipher.ts +153 -144
  29. package/src/crypto/derive.ts +64 -63
  30. package/src/crypto/key-provider.ts +78 -78
  31. package/src/crypto/provider.ts +81 -81
  32. package/src/diagnostics/dev-log.ts +112 -108
  33. package/src/diagnostics/errors.ts +62 -66
  34. package/src/diagnostics/faults.ts +58 -64
  35. package/src/diagnostics/map-native.ts +108 -106
  36. package/src/diagnostics/redact.ts +137 -141
  37. package/src/index.ts +80 -110
  38. package/src/migration/executor.ts +132 -136
  39. package/src/migration/ledger.ts +95 -97
  40. package/src/migration/validate-chain.ts +65 -65
  41. package/src/platform/backup-android-verify.ts +88 -88
  42. package/src/platform/backup-ios.ts +48 -48
  43. package/src/platform/bytes.ts +144 -0
  44. package/src/platform/fs.ts +89 -89
  45. package/src/platform/location.ts +135 -135
  46. package/src/query/budgets.ts +54 -57
  47. package/src/query/compare.ts +91 -91
  48. package/src/query/cursor.ts +100 -99
  49. package/src/query/filter.ts +82 -82
  50. package/src/query/paginate.ts +51 -51
  51. package/src/query/sort.ts +55 -56
  52. package/src/query/validate.ts +171 -171
  53. package/src/schema/declare.ts +42 -45
  54. package/src/schema/validate-config.ts +158 -158
  55. package/src/scope/hash.ts +131 -134
  56. package/src/scope/normalize.ts +83 -90
  57. package/src/scope/validate.ts +83 -83
  58. package/src/sqlite/adapter.ts +26 -26
  59. package/src/sqlite/batch.ts +242 -232
  60. package/src/sqlite/catalog.ts +105 -108
  61. package/src/sqlite/connection.ts +227 -205
  62. package/src/sqlite/index-coverage.ts +54 -53
  63. package/src/sqlite/native.ts +83 -83
  64. package/src/sqlite/row-codec.ts +217 -201
  65. package/src/sqlite/sql-builder.ts +244 -244
  66. package/src/sqlite/transaction-queue.ts +87 -0
  67. package/src/sqlite/transactions.ts +33 -33
  68. package/src/sqlite/values.ts +24 -24
  69. package/src/testing/conformance/attachments.ts +138 -136
  70. package/src/testing/conformance/encryption.ts +150 -150
  71. package/src/testing/conformance/index.ts +161 -161
  72. package/src/testing/conformance/lifecycle.ts +99 -99
  73. package/src/testing/conformance/migrations.ts +177 -177
  74. package/src/testing/conformance/queries.ts +204 -167
  75. package/src/testing/conformance/records.ts +169 -169
  76. package/src/testing/conformance/scopes.ts +86 -88
  77. package/src/testing/conformance/transactions.ts +149 -149
  78. package/src/testing/faults.ts +21 -21
  79. package/src/testing/index.ts +17 -21
  80. package/src/testing/memory-adapter/collection.ts +214 -219
  81. package/src/testing/memory-adapter/index.ts +510 -388
  82. package/src/testing/memory-adapter/migrations.ts +117 -119
  83. package/src/testing/memory-adapter/reset.ts +50 -50
  84. package/src/testing/memory-adapter/store.ts +128 -134
@@ -1,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(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
- }
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
+ }