@bakery-framework/plugin-db-explorer 2.0.0-alpha.9 → 2.0.0-rc.2

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 (49) hide show
  1. package/package.json +5 -5
  2. package/src/access.ts +53 -17
  3. package/src/client/api.ts +48 -6
  4. package/src/client/bulk.ts +31 -7
  5. package/src/client/cell.ts +5 -5
  6. package/src/client/confirm.ts +11 -12
  7. package/src/client/csv-commit.ts +25 -5
  8. package/src/client/csv-map.ts +7 -7
  9. package/src/client/csv-model.ts +61 -8
  10. package/src/client/csv-pick.ts +1 -1
  11. package/src/client/csv-preview.ts +2 -2
  12. package/src/client/csv.ts +2 -2
  13. package/src/client/dom.ts +3 -5
  14. package/src/client/edit-session.ts +7 -7
  15. package/src/client/editors.ts +5 -5
  16. package/src/client/filter-builder.ts +6 -6
  17. package/src/client/fk.ts +9 -9
  18. package/src/client/grid-body.ts +4 -4
  19. package/src/client/grid-header.ts +2 -2
  20. package/src/client/grid-rowbar.ts +1 -1
  21. package/src/client/grid.ts +8 -9
  22. package/src/client/meta.ts +6 -6
  23. package/src/client/page.ts +4 -4
  24. package/src/client/panel.ts +8 -8
  25. package/src/client/relations.ts +4 -4
  26. package/src/client/save.ts +4 -4
  27. package/src/client/sidebar.ts +6 -6
  28. package/src/client/state.ts +4 -4
  29. package/src/client/statusbar.ts +6 -6
  30. package/src/client/structure.ts +8 -8
  31. package/src/client/tabs.ts +10 -10
  32. package/src/client/tabstrip.ts +3 -3
  33. package/src/client.ts +17 -17
  34. package/src/endpoints/common.ts +25 -6
  35. package/src/endpoints/graph.ts +5 -5
  36. package/src/endpoints/import.ts +5 -5
  37. package/src/endpoints/read.ts +70 -10
  38. package/src/endpoints/rows.ts +219 -14
  39. package/src/identity.ts +122 -52
  40. package/src/index.ts +1 -1
  41. package/src/policy.ts +4 -4
  42. package/src/preview.ts +5 -5
  43. package/src/setup.ts +16 -10
  44. package/src/shared/coerce.ts +21 -22
  45. package/src/shared/csv.ts +5 -5
  46. package/src/shared/filters.ts +6 -6
  47. package/src/shared/plan.ts +7 -7
  48. package/src/shell.ts +3 -3
  49. package/src/validate.ts +10 -10
@@ -1,5 +1,5 @@
1
1
  /**
2
- * CSV import — the rows, not the file.
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 — `onBadRow: 'skip'` reports it and
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 — the same "413 with
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 response.json.error(400, error?.message ?? 'Import failed')
86
+ return refuse('import', error)
87
87
  },
88
88
  )
89
89
  }
@@ -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 — a grid that draws edit affordances
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 — what the grid shows. */
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 — it walks them to find a usable
50
- * unique key when there is no primary key — and used to throw them away here.
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
- rowCount: table.rowCount,
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 — see the header of `shared/filters.ts`.
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: Number.parseInt(url.searchParams.get('page') || '1', 10),
164
- pageSize: Number.parseInt(url.searchParams.get('pageSize') || '50', 10),
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
- (error: any) => response.json.error(400, error.message),
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
+ }
@@ -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 — see the header of `identity.ts` for the three ways it is wrong.
7
+ * never used. See the header of `identity.ts` for the three ways it is wrong.
8
8
  *
9
- * **Optimistic concurrency** is the same predicate with `expect` appended:
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 — *the row moved on* or *the update was a
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 — which inside
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 — narrowing to it here would mean
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 — see `preview.ts`.
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
- return response.json.error(400, error?.message ?? 'The write failed')
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 — which is exactly why the insert goes through it rather than
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 — MySQL has no such clause and
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
- (error: any) => response.json.error(400, error?.message ?? 'Insert failed'),
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 — send {} to update without a concurrency check',
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
- for (let index = 0; index < prepared.length; index++) {
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 — the retry then double-applies whatever succeeded. Any
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 })