@bakery-framework/plugin-db-explorer 2.0.0-alpha.5 → 2.0.0-alpha.6
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/package.json +4 -4
- package/src/access.ts +188 -0
- package/src/client/api.ts +261 -0
- package/src/client/bulk.ts +361 -0
- package/src/client/cell.ts +139 -0
- package/src/client/confirm.ts +201 -0
- package/src/client/csv-commit.ts +185 -0
- package/src/client/csv-map.ts +274 -0
- package/src/client/csv-model.ts +420 -0
- package/src/client/csv-pick.ts +54 -0
- package/src/client/csv-preview.ts +91 -0
- package/src/client/csv.ts +104 -0
- package/src/client/dom.ts +144 -0
- package/src/client/edit-session.ts +219 -0
- package/src/client/editors.ts +283 -0
- package/src/client/filter-builder.ts +198 -0
- package/src/client/fk.ts +242 -0
- package/src/client/grid-body.ts +103 -0
- package/src/client/grid-header.ts +65 -0
- package/src/client/grid-rowbar.ts +64 -0
- package/src/client/grid.ts +466 -0
- package/src/client/meta.ts +188 -0
- package/src/client/page.ts +332 -0
- package/src/client/panel.ts +296 -0
- package/src/client/relations.ts +205 -0
- package/src/client/save.ts +209 -0
- package/src/client/sidebar.ts +110 -0
- package/src/client/state.ts +218 -0
- package/src/client/statusbar.ts +130 -0
- package/src/client/structure.ts +231 -0
- package/src/client/tabs.ts +224 -0
- package/src/client/tabstrip.ts +127 -0
- package/src/client.ts +374 -160
- package/src/endpoints/common.ts +122 -0
- package/src/endpoints/graph.ts +0 -0
- package/src/endpoints/import.ts +89 -0
- package/src/endpoints/read.ts +173 -0
- package/src/endpoints/rows.ts +435 -0
- package/src/identity.ts +391 -0
- package/src/index.ts +40 -45
- package/src/policy.ts +45 -0
- package/src/preview.ts +53 -0
- package/src/setup.ts +64 -81
- package/src/shared/coerce.ts +399 -0
- package/src/shared/csv.ts +235 -0
- package/src/shared/filters.ts +200 -0
- package/src/shared/plan.ts +164 -0
- package/src/shell.ts +187 -0
- package/src/validate.ts +295 -0
- package/src/credential.ts +0 -26
- package/src/endpoints.ts +0 -48
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The row write surface: insert, edit one, edit many, delete.
|
|
3
|
+
*
|
|
4
|
+
* **Every statement carries an explicit identity predicate**, built from the
|
|
5
|
+
* key the caller sent and checked against the table's declared identity first.
|
|
6
|
+
* The adapter's `update(table, rowid, row)` / `remove(table, rowid)` triple is
|
|
7
|
+
* never used — see the header of `identity.ts` for the three ways it is wrong.
|
|
8
|
+
*
|
|
9
|
+
* **Optimistic concurrency** is the same predicate with `expect` appended:
|
|
10
|
+
* identity ∧ expect. `changes === 0` therefore means one of two things, and the
|
|
11
|
+
* dialects will not tell them apart — *the row moved on* or *the update was a
|
|
12
|
+
* no-op*. MySQL reports 0 changed rows when an UPDATE sets a column to the
|
|
13
|
+
* value it already held, so a zero has to be probed rather than trusted, and
|
|
14
|
+
* the probe runs inside the same transaction as the UPDATE or it is answering
|
|
15
|
+
* about a different moment.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { Try } from '@bakery-framework/core/utils'
|
|
19
|
+
import type { JsonResponseData } from '@bakery-framework/core/utils/common'
|
|
20
|
+
import { response } from '@bakery-framework/core/utils/http'
|
|
21
|
+
import { getActiveDb } from '@bakery-framework/orm/connection'
|
|
22
|
+
import { DB } from '@bakery-framework/orm/orm'
|
|
23
|
+
import { qId } from '@bakery-framework/orm/schema-util'
|
|
24
|
+
import type { TableFacts } from '../identity'
|
|
25
|
+
import { overLimit } from '../policy'
|
|
26
|
+
import { conflictRollback, isRollbackSignal, previewRollback } from '../preview'
|
|
27
|
+
import {
|
|
28
|
+
type FieldError,
|
|
29
|
+
isRecord,
|
|
30
|
+
unguardableColumns,
|
|
31
|
+
validateInsertRow,
|
|
32
|
+
validateKey,
|
|
33
|
+
validatePartial,
|
|
34
|
+
} from '../validate'
|
|
35
|
+
import { beginWrite, type Envelope, invalid } from './common'
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* A conflict, as the caller sees it: which edit, which row, and what the row
|
|
39
|
+
* looks like now so the client can offer a diff rather than "try again".
|
|
40
|
+
*/
|
|
41
|
+
export interface Conflict {
|
|
42
|
+
index: number
|
|
43
|
+
key: Record<string, unknown>
|
|
44
|
+
reason: string
|
|
45
|
+
row: Record<string, unknown> | null
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* One row by its identity, read through the active connection — which inside
|
|
50
|
+
* `DB.transaction` is the transaction's own handle, not the pooled one.
|
|
51
|
+
*
|
|
52
|
+
* Built with `qId` and bound parameters (convention 8). `IS NULL` rather than
|
|
53
|
+
* `= ?` for a null, because `NULL = NULL` is unknown and the predicate would
|
|
54
|
+
* match nothing.
|
|
55
|
+
*/
|
|
56
|
+
async function selectRow(
|
|
57
|
+
table: TableFacts,
|
|
58
|
+
predicate: Record<string, unknown>,
|
|
59
|
+
): Promise<Record<string, unknown> | null> {
|
|
60
|
+
const columns = Object.keys(predicate)
|
|
61
|
+
if (!columns.length) return null
|
|
62
|
+
|
|
63
|
+
const params: unknown[] = []
|
|
64
|
+
const where = columns
|
|
65
|
+
.map(column => {
|
|
66
|
+
const value = predicate[column]
|
|
67
|
+
if (value === null) return `${qId(column)} IS NULL`
|
|
68
|
+
params.push(value)
|
|
69
|
+
return `${qId(column)} = ?`
|
|
70
|
+
})
|
|
71
|
+
.join(' AND ')
|
|
72
|
+
|
|
73
|
+
const row = await getActiveDb()
|
|
74
|
+
.query(`SELECT * FROM ${qId(table.name)} WHERE ${where} LIMIT 1`)
|
|
75
|
+
.get(...params)
|
|
76
|
+
return (row as Record<string, unknown> | undefined) ?? null
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* `where(a).and(b).and(c)…` over a predicate of any width.
|
|
81
|
+
*
|
|
82
|
+
* `any` for the value, because the builder's `WhereValue` is a union that also
|
|
83
|
+
* carries column references and subqueries — narrowing to it here would mean
|
|
84
|
+
* asserting a coerced database value is not one of those, which is true but
|
|
85
|
+
* unprovable at this end. The values themselves are already bound parameters.
|
|
86
|
+
*/
|
|
87
|
+
function chain<E extends { and(column: any, value?: any): E }>(
|
|
88
|
+
begin: (column: string, value: any) => E,
|
|
89
|
+
predicate: Record<string, any>,
|
|
90
|
+
): E {
|
|
91
|
+
const entries = Object.entries(predicate)
|
|
92
|
+
const [first, rest] = [entries[0]!, entries.slice(1)]
|
|
93
|
+
let executable = begin(first[0], first[1])
|
|
94
|
+
for (const [column, value] of rest) executable = executable.and(column, value)
|
|
95
|
+
return executable
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Turn a `RollbackSignal` back into a response, and rethrow anything else.
|
|
100
|
+
*
|
|
101
|
+
* The rethrow is the load-bearing half. A `catch` that answered 200 for every
|
|
102
|
+
* throw would report a failed write as a successful dry run — see `preview.ts`.
|
|
103
|
+
*/
|
|
104
|
+
function fromRollback(error: any): Envelope {
|
|
105
|
+
if (isRollbackSignal(error)) {
|
|
106
|
+
return error.status >= 400
|
|
107
|
+
? response.json.error(error.status, error.message, error.report)
|
|
108
|
+
: response.json.success(error.message, error.report, error.status)
|
|
109
|
+
}
|
|
110
|
+
return response.json.error(400, error?.message ?? 'The write failed')
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ---------------------------------------------------------------- POST /rows
|
|
114
|
+
|
|
115
|
+
export async function handleInsertRows(
|
|
116
|
+
req: Request,
|
|
117
|
+
): Promise<JsonResponseData<unknown>> {
|
|
118
|
+
const start = await beginWrite(req)
|
|
119
|
+
if (!start.ok) return start.response
|
|
120
|
+
const { table, body } = start
|
|
121
|
+
|
|
122
|
+
const rows = body.rows
|
|
123
|
+
if (!Array.isArray(rows) || !rows.length) {
|
|
124
|
+
return response.json.error(400, 'rows must be a non-empty array')
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Before validation and before any statement: a 413 has to be true when it
|
|
128
|
+
// says nothing was executed, and validating 100,000 rows to then refuse them
|
|
129
|
+
// is work done on behalf of a request that was never going to run.
|
|
130
|
+
const over = overLimit('insertRows', rows.length)
|
|
131
|
+
if (over) return response.json.error(413, over)
|
|
132
|
+
|
|
133
|
+
const errors: FieldError[] = []
|
|
134
|
+
const records: Record<string, unknown>[] = []
|
|
135
|
+
rows.forEach((row, index) => {
|
|
136
|
+
const validated = validateInsertRow(row, table, index)
|
|
137
|
+
errors.push(...validated.errors)
|
|
138
|
+
records.push(validated.values)
|
|
139
|
+
})
|
|
140
|
+
if (errors.length) return invalid(errors)
|
|
141
|
+
|
|
142
|
+
const returning = body.returning === true
|
|
143
|
+
|
|
144
|
+
return await Try.return(
|
|
145
|
+
async () => {
|
|
146
|
+
// `DB.Insert` already batches under the adapter's parameter ceiling and
|
|
147
|
+
// wraps multiple batches in one transaction, so there is nothing to add
|
|
148
|
+
// here — which is exactly why the insert goes through it rather than
|
|
149
|
+
// through the adapter's own `insert()`.
|
|
150
|
+
const insert = DB.Insert.into(table.name).values(records)
|
|
151
|
+
if (!returning) {
|
|
152
|
+
const result = await insert.run()
|
|
153
|
+
return response.json.success('inserted', {
|
|
154
|
+
inserted: Number(result.changes ?? 0),
|
|
155
|
+
})
|
|
156
|
+
}
|
|
157
|
+
// `RETURNING` is SQLite and Postgres only — MySQL has no such clause and
|
|
158
|
+
// answers with its own syntax error, which is loud and correct. It is not
|
|
159
|
+
// emulated: a re-SELECT would have to guess the generated keys, and
|
|
160
|
+
// guessing which rows were just written is the class of bug this whole
|
|
161
|
+
// module exists to avoid.
|
|
162
|
+
const written = await insert
|
|
163
|
+
.returning('*')
|
|
164
|
+
.array<Record<string, unknown>>()
|
|
165
|
+
return response.json.success('inserted', {
|
|
166
|
+
inserted: written.length,
|
|
167
|
+
rows: written,
|
|
168
|
+
})
|
|
169
|
+
},
|
|
170
|
+
(error: any) => response.json.error(400, error?.message ?? 'Insert failed'),
|
|
171
|
+
)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// --------------------------------------------------------------- PATCH /row
|
|
175
|
+
|
|
176
|
+
export async function handleUpdateRow(
|
|
177
|
+
req: Request,
|
|
178
|
+
): Promise<JsonResponseData<unknown>> {
|
|
179
|
+
const start = await beginWrite(req)
|
|
180
|
+
if (!start.ok) return start.response
|
|
181
|
+
const { table, body } = start
|
|
182
|
+
|
|
183
|
+
if (!isRecord(body.expect)) {
|
|
184
|
+
// Required, not optional. An update with no `expect` is a last-write-wins
|
|
185
|
+
// update, and making that the default is how two people editing the same
|
|
186
|
+
// row silently lose one of the two edits. `{}` is the explicit spelling of
|
|
187
|
+
// "I accept that".
|
|
188
|
+
return response.json.error(
|
|
189
|
+
400,
|
|
190
|
+
'expect is required — send {} to update without a concurrency check',
|
|
191
|
+
)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const key = validateKey(body.key, table, 0)
|
|
195
|
+
const set = validatePartial(body.set, table, 0, {
|
|
196
|
+
allowUncomparable: true,
|
|
197
|
+
label: 'set',
|
|
198
|
+
})
|
|
199
|
+
const expect = validatePartial(body.expect, table, 0, {
|
|
200
|
+
allowUncomparable: false,
|
|
201
|
+
label: 'expect',
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
const errors = [...key.errors, ...set.errors, ...expect.errors]
|
|
205
|
+
if (errors.length) return invalid(errors)
|
|
206
|
+
if (!Object.keys(set.values).length) {
|
|
207
|
+
return response.json.error(400, 'set names no columns')
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const unguardable = unguardableColumns(set.values, table)
|
|
211
|
+
if (unguardable.length && body.force !== true) {
|
|
212
|
+
return response.json.error(
|
|
213
|
+
400,
|
|
214
|
+
`${unguardable.join(', ')} cannot be guarded by expect; ` +
|
|
215
|
+
'pass force: true to overwrite without a concurrency check',
|
|
216
|
+
)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return await Try.return(
|
|
220
|
+
async () =>
|
|
221
|
+
await DB.transaction(async () => {
|
|
222
|
+
const predicate = { ...key.where, ...expect.values }
|
|
223
|
+
const result = await chain(
|
|
224
|
+
(column, value) =>
|
|
225
|
+
DB.Update.table(table.name).set(set.values).where(column, value),
|
|
226
|
+
predicate,
|
|
227
|
+
).run()
|
|
228
|
+
|
|
229
|
+
const changed = Number(result.changes ?? 0)
|
|
230
|
+
if (changed > 0) {
|
|
231
|
+
return response.json.success('updated', {
|
|
232
|
+
changed,
|
|
233
|
+
row: await selectRow(table, key.where),
|
|
234
|
+
})
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Zero. Probe, in this transaction, before calling it a conflict:
|
|
238
|
+
// MySQL reports zero changed rows for an UPDATE that set every column
|
|
239
|
+
// to the value it already held, which is a successful no-op and not a
|
|
240
|
+
// lost update. If the row still satisfies identity ∧ expect, that is
|
|
241
|
+
// what happened.
|
|
242
|
+
const unchanged = await selectRow(table, predicate)
|
|
243
|
+
if (unchanged) {
|
|
244
|
+
return response.json.success('unchanged', {
|
|
245
|
+
changed: 0,
|
|
246
|
+
row: unchanged,
|
|
247
|
+
})
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return response.json.error(409, 'The row changed since it was read', {
|
|
251
|
+
changed: 0,
|
|
252
|
+
row: await selectRow(table, key.where),
|
|
253
|
+
})
|
|
254
|
+
}),
|
|
255
|
+
fromRollback,
|
|
256
|
+
)
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ---------------------------------------------------------- POST /rows/bulk
|
|
260
|
+
|
|
261
|
+
export async function handleBulkEdit(
|
|
262
|
+
req: Request,
|
|
263
|
+
): Promise<JsonResponseData<unknown>> {
|
|
264
|
+
const start = await beginWrite(req)
|
|
265
|
+
if (!start.ok) return start.response
|
|
266
|
+
const { table, body } = start
|
|
267
|
+
|
|
268
|
+
const edits = body.edits
|
|
269
|
+
if (!Array.isArray(edits) || !edits.length) {
|
|
270
|
+
return response.json.error(400, 'edits must be a non-empty array')
|
|
271
|
+
}
|
|
272
|
+
const over = overLimit('bulkEdits', edits.length)
|
|
273
|
+
if (over) return response.json.error(413, over)
|
|
274
|
+
|
|
275
|
+
const errors: FieldError[] = []
|
|
276
|
+
const prepared = edits.map((edit, index) => {
|
|
277
|
+
if (!isRecord(edit)) {
|
|
278
|
+
errors.push({
|
|
279
|
+
row: index,
|
|
280
|
+
column: '',
|
|
281
|
+
code: 'not_an_edit',
|
|
282
|
+
message: 'expected an object',
|
|
283
|
+
})
|
|
284
|
+
return null
|
|
285
|
+
}
|
|
286
|
+
const key = validateKey(edit.key, table, index)
|
|
287
|
+
const set = validatePartial(edit.set, table, index, {
|
|
288
|
+
allowUncomparable: true,
|
|
289
|
+
label: 'set',
|
|
290
|
+
})
|
|
291
|
+
const expect = validatePartial(edit.expect ?? {}, table, index, {
|
|
292
|
+
allowUncomparable: false,
|
|
293
|
+
label: 'expect',
|
|
294
|
+
})
|
|
295
|
+
errors.push(...key.errors, ...set.errors, ...expect.errors)
|
|
296
|
+
if (!Object.keys(set.values).length) {
|
|
297
|
+
errors.push({
|
|
298
|
+
row: index,
|
|
299
|
+
column: '',
|
|
300
|
+
code: 'empty_set',
|
|
301
|
+
message: 'set names no columns',
|
|
302
|
+
})
|
|
303
|
+
}
|
|
304
|
+
return { key: key.where, set: set.values, expect: expect.values }
|
|
305
|
+
})
|
|
306
|
+
if (errors.length) return invalid(errors)
|
|
307
|
+
|
|
308
|
+
const dryRun = body.dryRun === true
|
|
309
|
+
|
|
310
|
+
return await Try.return(
|
|
311
|
+
async () =>
|
|
312
|
+
await DB.transaction(async () => {
|
|
313
|
+
const conflicts: Conflict[] = []
|
|
314
|
+
let changed = 0
|
|
315
|
+
|
|
316
|
+
for (let index = 0; index < prepared.length; index++) {
|
|
317
|
+
const edit = prepared[index]!
|
|
318
|
+
const predicate = { ...edit.key, ...edit.expect }
|
|
319
|
+
const result = await chain(
|
|
320
|
+
(column, value) =>
|
|
321
|
+
DB.Update.table(table.name).set(edit.set).where(column, value),
|
|
322
|
+
predicate,
|
|
323
|
+
).run()
|
|
324
|
+
|
|
325
|
+
const rows = Number(result.changes ?? 0)
|
|
326
|
+
if (rows > 0) {
|
|
327
|
+
changed += rows
|
|
328
|
+
continue
|
|
329
|
+
}
|
|
330
|
+
// Same MySQL no-op probe as the single-row path.
|
|
331
|
+
if (await selectRow(table, predicate)) continue
|
|
332
|
+
conflicts.push({
|
|
333
|
+
index,
|
|
334
|
+
key: edit.key,
|
|
335
|
+
reason: 'the row changed since it was read',
|
|
336
|
+
row: await selectRow(table, edit.key),
|
|
337
|
+
})
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// **All or nothing.** A bulk edit is one action from the user's side,
|
|
341
|
+
// and a partial apply leaves them with no way to know which half
|
|
342
|
+
// landed — the retry then double-applies whatever succeeded. Any
|
|
343
|
+
// conflict rolls the whole transaction back.
|
|
344
|
+
if (conflicts.length) conflictRollback({ changed: 0, conflicts })
|
|
345
|
+
if (dryRun) previewRollback({ changed, conflicts })
|
|
346
|
+
return response.json.success('updated', { changed, conflicts })
|
|
347
|
+
}),
|
|
348
|
+
fromRollback,
|
|
349
|
+
)
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// -------------------------------------------------------------- DELETE /rows
|
|
353
|
+
|
|
354
|
+
export async function handleDeleteRows(
|
|
355
|
+
req: Request,
|
|
356
|
+
): Promise<JsonResponseData<unknown>> {
|
|
357
|
+
const start = await beginWrite(req)
|
|
358
|
+
if (!start.ok) return start.response
|
|
359
|
+
const { table, body } = start
|
|
360
|
+
|
|
361
|
+
const keys = body.keys
|
|
362
|
+
if (!Array.isArray(keys) || !keys.length) {
|
|
363
|
+
return response.json.error(400, 'keys must be a non-empty array')
|
|
364
|
+
}
|
|
365
|
+
const over = overLimit('deleteKeys', keys.length)
|
|
366
|
+
if (over) return response.json.error(413, over)
|
|
367
|
+
|
|
368
|
+
// Parallel to `keys`, not one shared object: a delete guarded by "the row
|
|
369
|
+
// still looks like this" needs a different expectation per row, and a single
|
|
370
|
+
// shared one would only ever be right for a single-row delete.
|
|
371
|
+
const expectations = body.expect
|
|
372
|
+
if (expectations !== undefined) {
|
|
373
|
+
if (!Array.isArray(expectations) || expectations.length !== keys.length) {
|
|
374
|
+
return response.json.error(
|
|
375
|
+
400,
|
|
376
|
+
'expect must be an array parallel to keys',
|
|
377
|
+
)
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const errors: FieldError[] = []
|
|
382
|
+
const prepared = keys.map((key, index) => {
|
|
383
|
+
const validated = validateKey(key, table, index)
|
|
384
|
+
errors.push(...validated.errors)
|
|
385
|
+
const raw = Array.isArray(expectations) ? expectations[index] : undefined
|
|
386
|
+
const expect =
|
|
387
|
+
raw === undefined || raw === null
|
|
388
|
+
? { values: {}, errors: [] }
|
|
389
|
+
: validatePartial(raw, table, index, {
|
|
390
|
+
allowUncomparable: false,
|
|
391
|
+
label: 'expect',
|
|
392
|
+
})
|
|
393
|
+
errors.push(...expect.errors)
|
|
394
|
+
return { key: validated.where, expect: expect.values }
|
|
395
|
+
})
|
|
396
|
+
if (errors.length) return invalid(errors)
|
|
397
|
+
|
|
398
|
+
const dryRun = body.dryRun === true
|
|
399
|
+
|
|
400
|
+
return await Try.return(
|
|
401
|
+
async () =>
|
|
402
|
+
await DB.transaction(async () => {
|
|
403
|
+
const conflicts: Conflict[] = []
|
|
404
|
+
let deleted = 0
|
|
405
|
+
|
|
406
|
+
for (let index = 0; index < prepared.length; index++) {
|
|
407
|
+
const target = prepared[index]!
|
|
408
|
+
const predicate = { ...target.key, ...target.expect }
|
|
409
|
+
const result = await chain(
|
|
410
|
+
(column, value) => DB.Delete.from(table.name).where(column, value),
|
|
411
|
+
predicate,
|
|
412
|
+
).run()
|
|
413
|
+
|
|
414
|
+
const rows = Number(result.changes ?? 0)
|
|
415
|
+
if (rows > 0) {
|
|
416
|
+
deleted += rows
|
|
417
|
+
continue
|
|
418
|
+
}
|
|
419
|
+
// No no-op case here: a DELETE that matched a row always reports it.
|
|
420
|
+
// Zero means the row is not there, or no longer matches `expect`.
|
|
421
|
+
conflicts.push({
|
|
422
|
+
index,
|
|
423
|
+
key: target.key,
|
|
424
|
+
reason: 'the row is gone or no longer matches expect',
|
|
425
|
+
row: await selectRow(table, target.key),
|
|
426
|
+
})
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
if (conflicts.length) conflictRollback({ deleted: 0, conflicts })
|
|
430
|
+
if (dryRun) previewRollback({ deleted, conflicts })
|
|
431
|
+
return response.json.success('deleted', { deleted, conflicts })
|
|
432
|
+
}),
|
|
433
|
+
fromRollback,
|
|
434
|
+
)
|
|
435
|
+
}
|