@bakery-framework/plugin-db-explorer 2.0.0-alpha.9 → 2.0.0-rc.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/package.json +5 -5
- package/src/access.ts +53 -17
- package/src/client/api.ts +48 -6
- package/src/client/bulk.ts +31 -7
- package/src/client/cell.ts +5 -5
- package/src/client/confirm.ts +11 -12
- package/src/client/csv-commit.ts +25 -5
- package/src/client/csv-map.ts +7 -7
- package/src/client/csv-model.ts +61 -8
- package/src/client/csv-pick.ts +1 -1
- package/src/client/csv-preview.ts +2 -2
- package/src/client/csv.ts +2 -2
- package/src/client/dom.ts +3 -5
- package/src/client/edit-session.ts +7 -7
- package/src/client/editors.ts +5 -5
- package/src/client/filter-builder.ts +6 -6
- package/src/client/fk.ts +9 -9
- package/src/client/grid-body.ts +4 -4
- package/src/client/grid-header.ts +2 -2
- package/src/client/grid-rowbar.ts +1 -1
- package/src/client/grid.ts +8 -9
- package/src/client/meta.ts +6 -6
- package/src/client/page.ts +4 -4
- package/src/client/panel.ts +8 -8
- package/src/client/relations.ts +4 -4
- package/src/client/save.ts +4 -4
- package/src/client/sidebar.ts +6 -6
- package/src/client/state.ts +4 -4
- package/src/client/statusbar.ts +6 -6
- package/src/client/structure.ts +8 -8
- package/src/client/tabs.ts +10 -10
- package/src/client/tabstrip.ts +3 -3
- package/src/client.ts +17 -17
- package/src/endpoints/common.ts +25 -6
- package/src/endpoints/graph.ts +5 -5
- package/src/endpoints/import.ts +5 -5
- package/src/endpoints/read.ts +70 -10
- package/src/endpoints/rows.ts +219 -14
- package/src/identity.ts +122 -52
- package/src/index.ts +1 -1
- package/src/policy.ts +4 -4
- package/src/preview.ts +5 -5
- package/src/setup.ts +16 -10
- package/src/shared/coerce.ts +21 -22
- package/src/shared/csv.ts +5 -5
- package/src/shared/filters.ts +6 -6
- package/src/shared/plan.ts +7 -7
- package/src/shell.ts +3 -3
- package/src/validate.ts +10 -10
package/src/endpoints/import.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* CSV import
|
|
2
|
+
* CSV import: the rows, not the file.
|
|
3
3
|
*
|
|
4
4
|
* The parse happens in the browser, in `shared/csv.ts`, and stays there. This
|
|
5
5
|
* endpoint takes rows that are already records, so the mapping the user
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*
|
|
9
9
|
* Two things separate it from `POST /api/_db/rows`, and both are about scale:
|
|
10
10
|
* the bound is a spreadsheet's worth of rows rather than an edit's, and a bad
|
|
11
|
-
* row does not have to end the whole import
|
|
11
|
+
* row does not have to end the whole import: `onBadRow: 'skip'` reports it and
|
|
12
12
|
* carries on, which is what a 50,000-row file with three malformed lines needs.
|
|
13
13
|
*/
|
|
14
14
|
|
|
@@ -19,7 +19,7 @@ import { DB } from '@bakery-framework/orm/orm'
|
|
|
19
19
|
import { overLimit } from '../policy'
|
|
20
20
|
import { isRollbackSignal, previewRollback } from '../preview'
|
|
21
21
|
import { type FieldError, validateInsertRow } from '../validate'
|
|
22
|
-
import { beginWrite, invalid } from './common'
|
|
22
|
+
import { beginWrite, invalid, refuse } from './common'
|
|
23
23
|
|
|
24
24
|
export type OnBadRow = 'stop' | 'skip'
|
|
25
25
|
|
|
@@ -56,7 +56,7 @@ export async function handleImport(
|
|
|
56
56
|
records.push(validated.values)
|
|
57
57
|
})
|
|
58
58
|
|
|
59
|
-
// `stop` refuses the whole file before a statement runs
|
|
59
|
+
// `stop` refuses the whole file before a statement runs: the same "413 with
|
|
60
60
|
// nothing executed" promise, for a 400.
|
|
61
61
|
if (onBadRow === 'stop' && errors.length) return invalid(errors)
|
|
62
62
|
|
|
@@ -83,7 +83,7 @@ export async function handleImport(
|
|
|
83
83
|
if (isRollbackSignal(error)) {
|
|
84
84
|
return response.json.success(error.message, error.report, error.status)
|
|
85
85
|
}
|
|
86
|
-
return
|
|
86
|
+
return refuse('import', error)
|
|
87
87
|
},
|
|
88
88
|
)
|
|
89
89
|
}
|
package/src/endpoints/read.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* `/api/_db/schema` now answers with more than the schema: the caller's own
|
|
5
5
|
* access level, and per table whether it is writable and why not. The client
|
|
6
|
-
* needs its posture *before* it renders
|
|
6
|
+
* needs its posture *before* it renders: a grid that draws edit affordances
|
|
7
7
|
* and then discovers on save that the table has no primary key has already
|
|
8
8
|
* wasted the user's work.
|
|
9
9
|
*/
|
|
@@ -15,10 +15,11 @@ import { connection } from '@bakery-framework/orm/connection'
|
|
|
15
15
|
import { currentAccess, currentCanWrite } from '../access'
|
|
16
16
|
import { type Identity, introspect } from '../identity'
|
|
17
17
|
import { parseFilters } from '../shared/filters'
|
|
18
|
+
import { refuse } from './common'
|
|
18
19
|
|
|
19
20
|
export interface SchemaColumn {
|
|
20
21
|
name: string
|
|
21
|
-
/** The database's own type string, unchanged
|
|
22
|
+
/** The database's own type string, unchanged: what the grid shows. */
|
|
22
23
|
type: string
|
|
23
24
|
notnull: boolean
|
|
24
25
|
pk: boolean
|
|
@@ -46,8 +47,8 @@ export interface SchemaTable {
|
|
|
46
47
|
/**
|
|
47
48
|
* Declared indexes.
|
|
48
49
|
*
|
|
49
|
-
* `introspect()` has always computed these
|
|
50
|
-
* unique key when there is no primary key
|
|
50
|
+
* `introspect()` has always computed these (it walks them to find a usable
|
|
51
|
+
* unique key when there is no primary key), and used to throw them away here.
|
|
51
52
|
* The Structure view is the first thing that shows them, and there is no
|
|
52
53
|
* other endpoint that knows them.
|
|
53
54
|
*/
|
|
@@ -67,7 +68,7 @@ export interface SchemaReport {
|
|
|
67
68
|
export async function handleSchema(): Promise<JsonResponseData<unknown>> {
|
|
68
69
|
return await Try.return(
|
|
69
70
|
async () => {
|
|
70
|
-
const tables = await introspect()
|
|
71
|
+
const tables = await introspect({ rowCounts: true })
|
|
71
72
|
const access = currentAccess()
|
|
72
73
|
const canWrite = currentCanWrite()
|
|
73
74
|
|
|
@@ -82,7 +83,9 @@ export async function handleSchema(): Promise<JsonResponseData<unknown>> {
|
|
|
82
83
|
: table.identity.reason
|
|
83
84
|
return {
|
|
84
85
|
name: table.name,
|
|
85
|
-
|
|
86
|
+
// The listing asked for counts, so null cannot occur here; the
|
|
87
|
+
// fallback keeps the payload type honest rather than asserting.
|
|
88
|
+
rowCount: table.rowCount ?? 0,
|
|
86
89
|
columns: table.columns.map(column => ({
|
|
87
90
|
name: column.name,
|
|
88
91
|
type: column.sqlType,
|
|
@@ -110,6 +113,32 @@ export async function handleSchema(): Promise<JsonResponseData<unknown>> {
|
|
|
110
113
|
)
|
|
111
114
|
}
|
|
112
115
|
|
|
116
|
+
/**
|
|
117
|
+
* A total the client counted on an earlier page, or `undefined`.
|
|
118
|
+
*
|
|
119
|
+
* The `COUNT(*)` is 97% of what a page costs - 51.3 ms against 1.6 ms for the
|
|
120
|
+
* rows on a filtered page of a 200,000-row table - and page 2 of a listing is
|
|
121
|
+
* asking the same question page 1 already answered.
|
|
122
|
+
*
|
|
123
|
+
* **Only from page 2 onwards.** The first page of any listing, and any request
|
|
124
|
+
* that arrives without a page, counts for real. That is what makes the value
|
|
125
|
+
* self-correcting: a client whose total has gone stale sees it fixed as soon
|
|
126
|
+
* as it returns to the first page or changes its filters, because changing
|
|
127
|
+
* filters restarts at page 1.
|
|
128
|
+
*
|
|
129
|
+
* The client asserts it counted with *these* filters; nothing here can check
|
|
130
|
+
* that, and nothing needs to. The total decides the row readout and the page
|
|
131
|
+
* count, never which rows are returned, so a wrong one is a stale number on
|
|
132
|
+
* screen rather than wrong data.
|
|
133
|
+
*/
|
|
134
|
+
function readKnownTotal(url: URL, page: number): number | undefined {
|
|
135
|
+
if (page <= 1) return undefined
|
|
136
|
+
const raw = url.searchParams.get('knownTotal')
|
|
137
|
+
if (raw === null) return undefined
|
|
138
|
+
const parsed = Number(raw)
|
|
139
|
+
return Number.isFinite(parsed) && parsed >= 0 ? parsed : undefined
|
|
140
|
+
}
|
|
141
|
+
|
|
113
142
|
/** Table names the way the ORM writes them: identifier characters only. */
|
|
114
143
|
const RX_TABLE_NAME = /^[a-zA-Z0-9_]+$/
|
|
115
144
|
|
|
@@ -121,7 +150,7 @@ const RX_TABLE_NAME = /^[a-zA-Z0-9_]+$/
|
|
|
121
150
|
* filter set: `JSON.parse` throwing on a mangled parameter, and `parseFilters`
|
|
122
151
|
* rejecting an operator the ORM would otherwise drop. A dropped filter *widens*
|
|
123
152
|
* the result, and the explorer's Delete acts on a selection made from this
|
|
124
|
-
* view
|
|
153
|
+
* view. See the header of `shared/filters.ts`.
|
|
125
154
|
*/
|
|
126
155
|
function readFilters(
|
|
127
156
|
url: URL,
|
|
@@ -159,15 +188,46 @@ export async function handleTableData(
|
|
|
159
188
|
|
|
160
189
|
return await Try.return(
|
|
161
190
|
async () => {
|
|
191
|
+
const page = readBounded(url, 'page', 1, 1, Number.MAX_SAFE_INTEGER)
|
|
162
192
|
const data = await connection.getData(tableName, {
|
|
163
|
-
page
|
|
164
|
-
pageSize:
|
|
193
|
+
page,
|
|
194
|
+
pageSize: readBounded(url, 'pageSize', 50, 1, MAX_PAGE_SIZE),
|
|
165
195
|
sortBy: url.searchParams.get('sortBy'),
|
|
166
196
|
sortOrder: url.searchParams.get('sortOrder') || 'ASC',
|
|
167
197
|
filters: filters.filters,
|
|
198
|
+
knownTotal: readKnownTotal(url, page),
|
|
168
199
|
})
|
|
169
200
|
return response.json.success('success', data)
|
|
170
201
|
},
|
|
171
|
-
|
|
202
|
+
error => refuse('table-data', error),
|
|
172
203
|
)
|
|
173
204
|
}
|
|
205
|
+
|
|
206
|
+
/** The largest page the read endpoint will assemble. */
|
|
207
|
+
const MAX_PAGE_SIZE = 500
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* A positive integer from the query string, clamped.
|
|
211
|
+
*
|
|
212
|
+
* The read side was the only unbounded surface left: every write goes through
|
|
213
|
+
* `policy.ts`, and this took `page` and `pageSize` as whatever
|
|
214
|
+
* `Number.parseInt` returned. `pageSize=1000000` assembled 50,000 rows and
|
|
215
|
+
* 5.25 MB in one response, `pageSize=-1` returned the whole table with a
|
|
216
|
+
* negative `totalPages`, and `page=0` silently served page 1. None needs a
|
|
217
|
+
* credential beyond `read`.
|
|
218
|
+
*
|
|
219
|
+
* `NaN` falls back rather than clamping: `page=abc` is a malformed request,
|
|
220
|
+
* and answering it with page 1 is friendlier than a 400 for a value that
|
|
221
|
+
* changes nothing about what the caller may see.
|
|
222
|
+
*/
|
|
223
|
+
function readBounded(
|
|
224
|
+
url: URL,
|
|
225
|
+
name: string,
|
|
226
|
+
fallback: number,
|
|
227
|
+
min: number,
|
|
228
|
+
max: number,
|
|
229
|
+
): number {
|
|
230
|
+
const raw = Number.parseInt(url.searchParams.get(name) || '', 10)
|
|
231
|
+
if (!Number.isFinite(raw)) return fallback
|
|
232
|
+
return Math.min(max, Math.max(min, raw))
|
|
233
|
+
}
|
package/src/endpoints/rows.ts
CHANGED
|
@@ -4,11 +4,11 @@
|
|
|
4
4
|
* **Every statement carries an explicit identity predicate**, built from the
|
|
5
5
|
* key the caller sent and checked against the table's declared identity first.
|
|
6
6
|
* The adapter's `update(table, rowid, row)` / `remove(table, rowid)` triple is
|
|
7
|
-
* never used
|
|
7
|
+
* never used. See the header of `identity.ts` for the three ways it is wrong.
|
|
8
8
|
*
|
|
9
|
-
* **
|
|
9
|
+
* **Optimiztic concurrency** is the same predicate with `expect` appended:
|
|
10
10
|
* identity ∧ expect. `changes === 0` therefore means one of two things, and the
|
|
11
|
-
* dialects will not tell them apart
|
|
11
|
+
* dialects will not tell them apart: *the row moved on* or *the update was a
|
|
12
12
|
* no-op*. MySQL reports 0 changed rows when an UPDATE sets a column to the
|
|
13
13
|
* value it already held, so a zero has to be probed rather than trusted, and
|
|
14
14
|
* the probe runs inside the same transaction as the UPDATE or it is answering
|
|
@@ -32,7 +32,7 @@ import {
|
|
|
32
32
|
validateKey,
|
|
33
33
|
validatePartial,
|
|
34
34
|
} from '../validate'
|
|
35
|
-
import { beginWrite, type Envelope, invalid } from './common'
|
|
35
|
+
import { beginWrite, type Envelope, invalid, refuse } from './common'
|
|
36
36
|
|
|
37
37
|
/**
|
|
38
38
|
* A conflict, as the caller sees it: which edit, which row, and what the row
|
|
@@ -46,7 +46,7 @@ export interface Conflict {
|
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
/**
|
|
49
|
-
* One row by its identity, read through the active connection
|
|
49
|
+
* One row by its identity, read through the active connection, which inside
|
|
50
50
|
* `DB.transaction` is the transaction's own handle, not the pooled one.
|
|
51
51
|
*
|
|
52
52
|
* Built with `qId` and bound parameters (convention 8). `IS NULL` rather than
|
|
@@ -80,7 +80,7 @@ async function selectRow(
|
|
|
80
80
|
* `where(a).and(b).and(c)…` over a predicate of any width.
|
|
81
81
|
*
|
|
82
82
|
* `any` for the value, because the builder's `WhereValue` is a union that also
|
|
83
|
-
* carries column references and subqueries
|
|
83
|
+
* carries column references and subqueries: narrowing to it here would mean
|
|
84
84
|
* asserting a coerced database value is not one of those, which is true but
|
|
85
85
|
* unprovable at this end. The values themselves are already bound parameters.
|
|
86
86
|
*/
|
|
@@ -99,7 +99,7 @@ function chain<E extends { and(column: any, value?: any): E }>(
|
|
|
99
99
|
* Turn a `RollbackSignal` back into a response, and rethrow anything else.
|
|
100
100
|
*
|
|
101
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
|
|
102
|
+
* throw would report a failed write as a successful dry run. See `preview.ts`.
|
|
103
103
|
*/
|
|
104
104
|
function fromRollback(error: any): Envelope {
|
|
105
105
|
if (isRollbackSignal(error)) {
|
|
@@ -107,7 +107,10 @@ function fromRollback(error: any): Envelope {
|
|
|
107
107
|
? response.json.error(error.status, error.message, error.report)
|
|
108
108
|
: response.json.success(error.message, error.report, error.status)
|
|
109
109
|
}
|
|
110
|
-
|
|
110
|
+
// A rollback signal carries a message this plugin wrote, so it is safe to
|
|
111
|
+
// pass on. Anything else reaching here is the driver's, and the driver's
|
|
112
|
+
// text is not the caller's business. See `refuse`.
|
|
113
|
+
return refuse('write', error)
|
|
111
114
|
}
|
|
112
115
|
|
|
113
116
|
// ---------------------------------------------------------------- POST /rows
|
|
@@ -145,7 +148,7 @@ export async function handleInsertRows(
|
|
|
145
148
|
async () => {
|
|
146
149
|
// `DB.Insert` already batches under the adapter's parameter ceiling and
|
|
147
150
|
// wraps multiple batches in one transaction, so there is nothing to add
|
|
148
|
-
// here
|
|
151
|
+
// here, which is exactly why the insert goes through it rather than
|
|
149
152
|
// through the adapter's own `insert()`.
|
|
150
153
|
const insert = DB.Insert.into(table.name).values(records)
|
|
151
154
|
if (!returning) {
|
|
@@ -154,7 +157,7 @@ export async function handleInsertRows(
|
|
|
154
157
|
inserted: Number(result.changes ?? 0),
|
|
155
158
|
})
|
|
156
159
|
}
|
|
157
|
-
// `RETURNING` is SQLite and Postgres only
|
|
160
|
+
// `RETURNING` is SQLite and Postgres only: MySQL has no such clause and
|
|
158
161
|
// answers with its own syntax error, which is loud and correct. It is not
|
|
159
162
|
// emulated: a re-SELECT would have to guess the generated keys, and
|
|
160
163
|
// guessing which rows were just written is the class of bug this whole
|
|
@@ -167,7 +170,7 @@ export async function handleInsertRows(
|
|
|
167
170
|
rows: written,
|
|
168
171
|
})
|
|
169
172
|
},
|
|
170
|
-
|
|
173
|
+
error => refuse('insert', error),
|
|
171
174
|
)
|
|
172
175
|
}
|
|
173
176
|
|
|
@@ -187,7 +190,7 @@ export async function handleUpdateRow(
|
|
|
187
190
|
// "I accept that".
|
|
188
191
|
return response.json.error(
|
|
189
192
|
400,
|
|
190
|
-
'expect is required
|
|
193
|
+
'expect is required: send {} to update without a concurrency check',
|
|
191
194
|
)
|
|
192
195
|
}
|
|
193
196
|
|
|
@@ -256,6 +259,202 @@ export async function handleUpdateRow(
|
|
|
256
259
|
)
|
|
257
260
|
}
|
|
258
261
|
|
|
262
|
+
type PreparedEdit = {
|
|
263
|
+
key: Record<string, unknown>
|
|
264
|
+
set: Record<string, unknown>
|
|
265
|
+
expect: Record<string, unknown>
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
type CollapsedGroup = {
|
|
269
|
+
/** The one identity column every member addresses. */
|
|
270
|
+
column: string
|
|
271
|
+
set: Record<string, unknown>
|
|
272
|
+
expect: Record<string, unknown>
|
|
273
|
+
/**
|
|
274
|
+
* The original edit index alongside its key value, so a conflict still names
|
|
275
|
+
* the edit the caller sent rather than a position in a regrouped list.
|
|
276
|
+
*/
|
|
277
|
+
members: { index: number; value: unknown }[]
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Split a bulk edit into runs that can share one statement and those that
|
|
282
|
+
* cannot.
|
|
283
|
+
*
|
|
284
|
+
* A bulk edit issued one `UPDATE` per row, so 1,000 edits were 1,000
|
|
285
|
+
* statements plus the transaction and the conflict probes on top. The rows
|
|
286
|
+
* almost always share their `set` and their `expect` - that is what makes it a
|
|
287
|
+
* *bulk* edit, one action applied to a selection - so grouping on those two
|
|
288
|
+
* plus the identity column collapses the common case to one statement per
|
|
289
|
+
* group.
|
|
290
|
+
*
|
|
291
|
+
* Three things disqualify a member, and each falls back rather than failing:
|
|
292
|
+
*
|
|
293
|
+
* - a composite identity, because `IN` addresses one column;
|
|
294
|
+
* - a `null` key value, because `IN` never matches NULL and `IS NULL` is a
|
|
295
|
+
* different clause. The single-row `where` already handles it correctly,
|
|
296
|
+
* so falling back is not a compromise;
|
|
297
|
+
* - being alone in its group, where one statement is one statement either
|
|
298
|
+
* way and the collapsed path would only add a probe.
|
|
299
|
+
*
|
|
300
|
+
* Exported as a test seam (convention 9). Two of the three disqualifications -
|
|
301
|
+
* a composite identity and a `null` key value - are hard to reach through the
|
|
302
|
+
* endpoint, because a NOT NULL primary key cannot carry a null and validation
|
|
303
|
+
* refuses it before this runs. The guard still earns its place: identity can
|
|
304
|
+
* be a unique index over a nullable column, and `IN` would silently match
|
|
305
|
+
* nothing there. Asserting it directly is the only honest way to pin it.
|
|
306
|
+
*/
|
|
307
|
+
export function groupBulkEdits(prepared: (PreparedEdit | null)[]): {
|
|
308
|
+
collapsible: CollapsedGroup[]
|
|
309
|
+
single: number[]
|
|
310
|
+
} {
|
|
311
|
+
const groups = new Map<string, CollapsedGroup>()
|
|
312
|
+
const single: number[] = []
|
|
313
|
+
|
|
314
|
+
for (let index = 0; index < prepared.length; index++) {
|
|
315
|
+
const edit = prepared[index]!
|
|
316
|
+
const columns = Object.keys(edit.key)
|
|
317
|
+
const column = columns[0]
|
|
318
|
+
const value = column === undefined ? undefined : edit.key[column]
|
|
319
|
+
|
|
320
|
+
if (columns.length !== 1 || column === undefined || value === null) {
|
|
321
|
+
single.push(index)
|
|
322
|
+
continue
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const signature = JSON.stringify([column, edit.set, edit.expect])
|
|
326
|
+
const group = groups.get(signature)
|
|
327
|
+
if (group) {
|
|
328
|
+
group.members.push({ index, value })
|
|
329
|
+
} else {
|
|
330
|
+
groups.set(signature, {
|
|
331
|
+
column,
|
|
332
|
+
set: edit.set,
|
|
333
|
+
expect: edit.expect,
|
|
334
|
+
members: [{ index, value }],
|
|
335
|
+
})
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const collapsible: CollapsedGroup[] = []
|
|
340
|
+
for (const group of groups.values()) {
|
|
341
|
+
if (group.members.length > 1) collapsible.push(group)
|
|
342
|
+
else single.push(group.members[0]!.index)
|
|
343
|
+
}
|
|
344
|
+
// The leftovers came out of a Map and have lost their order; they are
|
|
345
|
+
// applied in sequence below, so put them back in the order they were sent.
|
|
346
|
+
single.sort((a, b) => a - b)
|
|
347
|
+
|
|
348
|
+
return { collapsible, single }
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Apply one collapsed group, and report any member that cannot be applied.
|
|
353
|
+
*
|
|
354
|
+
* **The probe runs before the write, not after it**, and that ordering is the
|
|
355
|
+
* whole design. A collapsed `UPDATE` reports a row count, not identities, so a
|
|
356
|
+
* short count says *some* member did not match without saying which. Finding
|
|
357
|
+
* out afterwards is not possible: the update has already changed the columns
|
|
358
|
+
* `expect` refers to, and re-running the members one at a time inside the same
|
|
359
|
+
* transaction would apply them twice.
|
|
360
|
+
*
|
|
361
|
+
* So: ask which keys satisfy identity AND expect, treat the rest as conflicts,
|
|
362
|
+
* and update only when there are none. That answers the MySQL no-op case for
|
|
363
|
+
* free - a row that matches the probe but reports zero changed rows is a
|
|
364
|
+
* no-op, decided exactly as the single-row path decides it, and the probe has
|
|
365
|
+
* already established the row is there.
|
|
366
|
+
*
|
|
367
|
+
* Any conflict rolls the entire bulk edit back, so a group that has one skips
|
|
368
|
+
* its update rather than writing something about to be discarded.
|
|
369
|
+
*/
|
|
370
|
+
async function applyCollapsedGroup(
|
|
371
|
+
table: TableFacts,
|
|
372
|
+
group: CollapsedGroup,
|
|
373
|
+
conflicts: Conflict[],
|
|
374
|
+
): Promise<number> {
|
|
375
|
+
let changed = 0
|
|
376
|
+
|
|
377
|
+
// One bound parameter per key, plus the `set` and `expect` values, has to
|
|
378
|
+
// fit inside the dialect's statement limit. `maxQueryParams` is the
|
|
379
|
+
// adapter's own number; SQLite's 32,766 is the smallest of the three.
|
|
380
|
+
const fixed = Object.keys(group.set).length + Object.keys(group.expect).length
|
|
381
|
+
const limit = Math.max(1, getActiveDb().maxQueryParams - fixed - 1)
|
|
382
|
+
|
|
383
|
+
for (let start = 0; start < group.members.length; start += limit) {
|
|
384
|
+
const chunk = group.members.slice(start, start + limit)
|
|
385
|
+
|
|
386
|
+
const matched = await selectKeyValues(
|
|
387
|
+
table,
|
|
388
|
+
group.column,
|
|
389
|
+
chunk.map(m => m.value),
|
|
390
|
+
group.expect,
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
const missing = chunk.filter(m => !matched.has(m.value))
|
|
394
|
+
if (missing.length) {
|
|
395
|
+
for (const member of missing) {
|
|
396
|
+
const key = { [group.column]: member.value }
|
|
397
|
+
conflicts.push({
|
|
398
|
+
index: member.index,
|
|
399
|
+
key,
|
|
400
|
+
reason: 'the row changed since it was read',
|
|
401
|
+
row: await selectRow(table, key),
|
|
402
|
+
})
|
|
403
|
+
}
|
|
404
|
+
continue
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
let statement = DB.Update.table(table.name)
|
|
408
|
+
.set(group.set)
|
|
409
|
+
.where(group.column, DB.inList(chunk.map(m => m.value)))
|
|
410
|
+
for (const [column, value] of Object.entries(group.expect)) {
|
|
411
|
+
// `expect` has already been through `validatePartial`, which is what
|
|
412
|
+
// decides a value is comparable at all; the builder's parameter type is
|
|
413
|
+
// narrower than "whatever survived that" and the cast says so once here
|
|
414
|
+
// rather than loosening the validator.
|
|
415
|
+
statement = statement.and(column, value as never)
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const result = await statement.run()
|
|
419
|
+
changed += Number(result.changes ?? 0)
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
return changed
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Which of `values` name a row that also satisfies `expect`.
|
|
427
|
+
*
|
|
428
|
+
* A Set of the key values seen, so a key matching more than one row counts
|
|
429
|
+
* once: the question is whether the edit addresses anything, not how much.
|
|
430
|
+
*/
|
|
431
|
+
async function selectKeyValues(
|
|
432
|
+
table: TableFacts,
|
|
433
|
+
column: string,
|
|
434
|
+
values: unknown[],
|
|
435
|
+
expect: Record<string, unknown>,
|
|
436
|
+
): Promise<Set<unknown>> {
|
|
437
|
+
const params: unknown[] = [...values]
|
|
438
|
+
const clauses = [`${qId(column)} IN (${values.map(() => '?').join(', ')})`]
|
|
439
|
+
|
|
440
|
+
for (const [name, value] of Object.entries(expect)) {
|
|
441
|
+
if (value === null) {
|
|
442
|
+
clauses.push(`${qId(name)} IS NULL`)
|
|
443
|
+
} else {
|
|
444
|
+
params.push(value)
|
|
445
|
+
clauses.push(`${qId(name)} = ?`)
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const rows = (await getActiveDb()
|
|
450
|
+
.query(
|
|
451
|
+
`SELECT ${qId(column)} FROM ${qId(table.name)} WHERE ${clauses.join(' AND ')}`,
|
|
452
|
+
)
|
|
453
|
+
.all(...params)) as Record<string, unknown>[]
|
|
454
|
+
|
|
455
|
+
return new Set(rows.map(row => row[column]))
|
|
456
|
+
}
|
|
457
|
+
|
|
259
458
|
// ---------------------------------------------------------- POST /rows/bulk
|
|
260
459
|
|
|
261
460
|
export async function handleBulkEdit(
|
|
@@ -313,7 +512,13 @@ export async function handleBulkEdit(
|
|
|
313
512
|
const conflicts: Conflict[] = []
|
|
314
513
|
let changed = 0
|
|
315
514
|
|
|
316
|
-
|
|
515
|
+
const { collapsible, single } = groupBulkEdits(prepared)
|
|
516
|
+
|
|
517
|
+
for (const group of collapsible) {
|
|
518
|
+
changed += await applyCollapsedGroup(table, group, conflicts)
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
for (const index of single) {
|
|
317
522
|
const edit = prepared[index]!
|
|
318
523
|
const predicate = { ...edit.key, ...edit.expect }
|
|
319
524
|
const result = await chain(
|
|
@@ -339,7 +544,7 @@ export async function handleBulkEdit(
|
|
|
339
544
|
|
|
340
545
|
// **All or nothing.** A bulk edit is one action from the user's side,
|
|
341
546
|
// and a partial apply leaves them with no way to know which half
|
|
342
|
-
// landed
|
|
547
|
+
// landed: the retry then double-applies whatever succeeded. Any
|
|
343
548
|
// conflict rolls the whole transaction back.
|
|
344
549
|
if (conflicts.length) conflictRollback({ changed: 0, conflicts })
|
|
345
550
|
if (dryRun) previewRollback({ changed, conflicts })
|