@bakery-framework/plugin-db-explorer 2.0.0-alpha.12 → 2.0.0-alpha.13

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bakery-framework/plugin-db-explorer",
3
- "version": "2.0.0-alpha.12",
3
+ "version": "2.0.0-alpha.13",
4
4
  "description": "Bakery database explorer plugin — browse and edit rows — no raw SQL, no DDL.",
5
5
  "keywords": [
6
6
  "bakery",
@@ -33,8 +33,8 @@
33
33
  "!src/tests"
34
34
  ],
35
35
  "dependencies": {
36
- "@bakery-framework/core": "^2.0.0-alpha.12",
37
- "@bakery-framework/orm": "^2.0.0-alpha.12"
36
+ "@bakery-framework/core": "^2.0.0-alpha.13",
37
+ "@bakery-framework/orm": "^2.0.0-alpha.13"
38
38
  },
39
39
  "engines": {
40
40
  "bun": ">=1.4.0"
package/src/access.ts CHANGED
@@ -88,9 +88,16 @@ function presentedKey(req: Request): string | null {
88
88
  if (SAFE_METHODS.has(req.method)) return presented
89
89
 
90
90
  // `readCredential` prefers header, then Bearer, then the query — so if
91
- // neither header is present, the value it returned came from the URL.
92
- const fromHeader =
93
- req.headers.has(`x-${DB_KEY}`) || req.headers.has('authorization')
91
+ // neither header carried a value, what it returned came from the URL.
92
+ //
93
+ // **The value, not `has()`.** An empty header is *present*, so
94
+ // `x-db-key: ''` alongside `?db-key=SECRET` read as "a header was used" and
95
+ // let a URL credential through on a write — the one case this function
96
+ // exists to refuse. `readCredential` skips a falsy header and falls to the
97
+ // query, so the two checks disagreed about which form had been presented.
98
+ const fromHeader = Boolean(
99
+ req.headers.get(`x-${DB_KEY}`) || req.headers.get('authorization'),
100
+ )
94
101
  return fromHeader ? presented : null
95
102
  }
96
103
 
@@ -186,3 +193,32 @@ export function currentAccess(): Access | false {
186
193
  export function currentCanWrite(): boolean {
187
194
  return canWrite(currentAccess())
188
195
  }
196
+
197
+ /**
198
+ * Refuse a `users` map that names a level the explorer does not have.
199
+ *
200
+ * `access` is typed `Access`, and a JavaScript caller or a value read from the
201
+ * environment is not bound by that. An unknown level — `'admin'` is the one
202
+ * that gets written — passed straight through `higher`, reached the client as
203
+ * the caller's own level in `/api/_db/schema`, and then failed `canWrite`,
204
+ * which compares against `'write'` exactly. So it failed *closed*, which is
205
+ * the right direction and an unhelpful way to say "your configuration has a
206
+ * typo": the operator sees "no access" on a map they believe grants write.
207
+ *
208
+ * Thrown at registration rather than logged, because this is a configuration
209
+ * error in code the application controls, it cannot become correct later, and
210
+ * the alternative is a server that runs while quietly refusing the people it
211
+ * was set up to admit.
212
+ */
213
+ export function assertValidUsers(users: ExplorerUsers | undefined): void {
214
+ if (!users) return
215
+ for (const [name, user] of Object.entries(users)) {
216
+ if (user.access !== 'read' && user.access !== 'write') {
217
+ throw new Error(
218
+ `dbExplorerPlugin: users.${name}.access is ${JSON.stringify(
219
+ user.access,
220
+ )}; it must be 'read' or 'write'`,
221
+ )
222
+ }
223
+ }
224
+ }
package/src/client/api.ts CHANGED
@@ -167,6 +167,28 @@ export async function fetchGraph(): Promise<SchemaGraph> {
167
167
  * The scalar form is still what the endpoint accepts from anyone else; `toWire`
168
168
  * always sends the object form from here.
169
169
  */
170
+ /**
171
+ * What the last counted listing was, and what it counted.
172
+ *
173
+ * One slot, so it is bounded by construction (convention 6). The key is
174
+ * everything that changes which rows a listing covers - table, sort and
175
+ * filters - and *not* the page number, because paging through one listing is
176
+ * precisely the case where the total does not change.
177
+ */
178
+ let lastCounted: { signature: string; total: number } | null = null
179
+
180
+ function listingSignature(
181
+ view: ViewState,
182
+ wire: Record<string, unknown>,
183
+ ): string {
184
+ return JSON.stringify([view.table, view.sortBy ?? '', view.sortOrder, wire])
185
+ }
186
+
187
+ /** Test seam (convention 9): page 2 of one test must not see page 1 of another. */
188
+ export function __resetPageCount(): void {
189
+ lastCounted = null
190
+ }
191
+
170
192
  export async function fetchPage(view: ViewState): Promise<Timed<TablePage>> {
171
193
  const params: Record<string, string> = {
172
194
  tableName: view.table,
@@ -178,11 +200,31 @@ export async function fetchPage(view: ViewState): Promise<Timed<TablePage>> {
178
200
  const wire = toWire(view.filters)
179
201
  if (Object.keys(wire).length) params.filters = JSON.stringify(wire)
180
202
 
203
+ // The `COUNT(*)` is 97% of what a page costs - 51.3 ms against 1.6 ms for
204
+ // the rows on a filtered page of a 200,000-row table - and page 2 of a
205
+ // listing asks exactly what page 1 already answered.
206
+ //
207
+ // Sent only when the listing is the same one and this is not its first
208
+ // page. The server enforces the second condition too, so a total that has
209
+ // drifted is corrected as soon as the view returns to the start; changing a
210
+ // filter or a sort changes the signature, which is the other way back.
211
+ const signature = listingSignature(view, wire)
212
+ if (view.page > 1 && lastCounted?.signature === signature) {
213
+ params.knownTotal = String(lastCounted.total)
214
+ }
215
+
181
216
  const query = `?${new URLSearchParams(params)}`
182
217
  const res = await fetch(`/api/_db/table-data${query}`, {
183
218
  headers: keyHeaders(),
184
219
  })
185
- return await unwrapEnvelope<TablePage>(res)
220
+ const answer = await unwrapEnvelope<TablePage>(res)
221
+
222
+ // Remember what came back, whether it was counted or echoed: echoing keeps
223
+ // the slot on the same listing, and a real count refreshes it.
224
+ if (typeof answer.data?.totalRows === 'number') {
225
+ lastCounted = { signature, total: answer.data.totalRows }
226
+ }
227
+ return answer
186
228
  }
187
229
 
188
230
  export interface LookupRef {
@@ -213,7 +213,7 @@ function offerDeleteUndo(
213
213
  notify(`${deleted} rows deleted`)
214
214
  return
215
215
  }
216
- const row = rows[0]!
216
+ const row = restorableRow(rows[0]!, ctx.columns)
217
217
  const restore = async () => {
218
218
  await run(async () => {
219
219
  await insertRows({ table: ctx.table.name, rows: [row] })
@@ -355,3 +355,27 @@ async function run<T>(call: () => Promise<T>): Promise<T | null> {
355
355
  return null
356
356
  }
357
357
  }
358
+
359
+ /**
360
+ * A deleted row, reduced to the columns the table actually has.
361
+ *
362
+ * `selectedRows()` hands over the row as `getData` returned it, and two of the
363
+ * three dialects add a key that is not a column: SQLite selects `rowid` and
364
+ * Postgres `ctid::text AS rowid`, while MySQL selects `*` and so never showed
365
+ * this. `validateInsertRow` refuses unknown columns by design, so restoring
366
+ * the row verbatim came back `400 rowid: unknown_column` — the undo offered
367
+ * after a delete silently did not undo, on two dialects out of three.
368
+ *
369
+ * Exported for the test. There is no DOM here and the bug lived in a callback
370
+ * a click builds, so the only way to pin it is to name the transformation.
371
+ */
372
+ export function restorableRow(
373
+ source: Record<string, unknown>,
374
+ columns: { name: string }[],
375
+ ): Record<string, unknown> {
376
+ const row: Record<string, unknown> = {}
377
+ for (const column of columns) {
378
+ if (column.name in source) row[column.name] = source[column.name]
379
+ }
380
+ return row
381
+ }
@@ -22,8 +22,28 @@ import {
22
22
  import { append, box, button, downloadText, el } from './dom'
23
23
  import type { SchemaColumn, SchemaTable } from './meta'
24
24
 
25
- /** One request per chunk. Well under `policy.ts`'s 50,000 row ceiling. */
26
- const CHUNK = 500
25
+ /**
26
+ * One request per chunk. Well under `policy.ts`'s 50,000 row ceiling.
27
+ *
28
+ * Raised from 500, which made a full-size file 100 requests. Measured end to
29
+ * end on 20,000 rows through the real endpoint, three rounds against a
30
+ * CPU-bound control flat at 26-29 ms: **108 ms at 500 against 89 ms at 5,000**,
31
+ * an 18% saving and 0.53 ms of fixed per-request cost removed 36 times over.
32
+ *
33
+ * The reason on record for raising it was that each request re-introspected
34
+ * the schema, and that is no longer true - `introspect()` is cached against
35
+ * `schemaFingerprint()` now, so the second request onwards pays 10 us rather
36
+ * than 8.28 ms. What is left is HTTP, authorisation and a transaction per
37
+ * request, which is what the 18% is.
38
+ *
39
+ * **The cost is cancel granularity**, and it is worth stating plainly.
40
+ * `cancelled` is checked between chunks, so a chunk in flight always
41
+ * completes: cancelling used to leave at most 500 further rows inserted and
42
+ * now leaves at most 5,000. Progress also advances ten times less often. Both
43
+ * follow from the chunk size rather than from anything that could be tuned
44
+ * separately, since a chunk is one request and one transaction.
45
+ */
46
+ const CHUNK = 5000
27
47
 
28
48
  export interface CommitContext {
29
49
  table: SchemaTable
@@ -345,10 +345,54 @@ export interface BuildResult {
345
345
  * walks over fifty thousand rows is the difference between a responsive dialog
346
346
  * and a frozen tab.
347
347
  */
348
+ /**
349
+ * The last build, so a change that cannot alter it does not redo it.
350
+ *
351
+ * One slot, so it is bounded by construction (convention 6). Compared by
352
+ * **reference**, not by value: every update goes through a spread
353
+ * (`{ ...model, onBadRow }`), so an untouched field keeps its identity and a
354
+ * touched one does not. Comparing by value would mean walking the whole file
355
+ * to decide whether to walk the whole file.
356
+ *
357
+ * The key is exactly what this function reads - `headers`, `rows`, `assign`,
358
+ * `emptyToNull` and the columns - and deliberately not `onBadRow`, `ragged`,
359
+ * `delimiter` or `hasHeader`. Those change what the *import* does, never what
360
+ * the records are, and the bad-row policy is a dropdown in the same footer
361
+ * that displays this result. Picking one made the footer coerce every row of
362
+ * the file to arrive at the count it already had on screen.
363
+ *
364
+ * Measured on 50,000 rows of four columns: 153-250 ms per change, on the main
365
+ * thread, for a number that did not move.
366
+ */
367
+ let lastBuild: {
368
+ headers: unknown
369
+ rows: unknown
370
+ assign: unknown
371
+ emptyToNull: unknown
372
+ columns: unknown
373
+ result: BuildResult
374
+ } | null = null
375
+
376
+ /** Test seam (convention 9): one test's file must not answer for another's. */
377
+ export function __resetBuildRecords(): void {
378
+ lastBuild = null
379
+ }
380
+
348
381
  export function buildRecords(
349
382
  model: ImportModel,
350
383
  columns: readonly SchemaColumn[],
351
384
  ): BuildResult {
385
+ if (
386
+ lastBuild !== null &&
387
+ lastBuild.headers === model.headers &&
388
+ lastBuild.rows === model.rows &&
389
+ lastBuild.assign === model.assign &&
390
+ lastBuild.emptyToNull === model.emptyToNull &&
391
+ lastBuild.columns === columns
392
+ ) {
393
+ return lastBuild.result
394
+ }
395
+
352
396
  const feeds = feedsOf(model, columns)
353
397
  const records: Record<string, unknown>[] = []
354
398
  const failures: RowFailure[] = []
@@ -369,7 +413,16 @@ export function buildRecords(
369
413
  records.push(record)
370
414
  })
371
415
 
372
- return { records, failures }
416
+ const result = { records, failures }
417
+ lastBuild = {
418
+ headers: model.headers,
419
+ rows: model.rows,
420
+ assign: model.assign,
421
+ emptyToNull: model.emptyToNull,
422
+ columns,
423
+ result,
424
+ }
425
+ return result
373
426
  }
374
427
 
375
428
  /** RFC 4180 quoting: only when the field needs it, and `"` doubles. */
@@ -15,6 +15,7 @@
15
15
  * are about the request's own shape rather than about who is asking.
16
16
  */
17
17
 
18
+ import { errorMsg, pluginLog } from '@bakery-framework/core/logger'
18
19
  import { Case, Try } from '@bakery-framework/core/utils'
19
20
  import type { JsonResponseData } from '@bakery-framework/core/utils/common'
20
21
  import { response } from '@bakery-framework/core/utils/http'
@@ -120,3 +121,21 @@ export async function beginWrite(req: Request): Promise<WriteStart> {
120
121
 
121
122
  return { ok: true, table, body }
122
123
  }
124
+
125
+ /**
126
+ * Refuse a request without telling the caller what the database said.
127
+ *
128
+ * Every read endpoint used to answer `400` with `error.message` verbatim, so a
129
+ * malformed `page` came back as SQLite's "datatype mismatch" and a bad lookup
130
+ * key as a JavaScript `TypeError` naming an internal expression. Postgres is
131
+ * worse: its parse errors quote the statement. A caller holding only `read`
132
+ * cannot be handed the query text, and none of it helps a client that has
133
+ * already been told its request was invalid.
134
+ *
135
+ * The message still exists — it goes to the server log with the operation that
136
+ * produced it, which is where an operator can act on it.
137
+ */
138
+ export function refuse(op: string, error: unknown, status = 400): Envelope {
139
+ pluginLog.EXPLORER_QUERY_ERR({ op, error: errorMsg(error) })
140
+ return response.json.error(status, `${op} failed`)
141
+ }
@@ -15,7 +15,7 @@ import { connection } from '@bakery-framework/orm/connection'
15
15
  import { qId } from '@bakery-framework/orm/schema-util'
16
16
  import { type Identity, introspect, type TableFacts } from '../identity'
17
17
  import { overLimit } from '../policy'
18
- import { findTable, readBody } from './common'
18
+ import { findTable, readBody, refuse } from './common'
19
19
 
20
20
  export async function handleGraph(): Promise<JsonResponseData<unknown>> {
21
21
  return await Try.return(
@@ -143,6 +143,6 @@ export async function handleLookup(
143
143
 
144
144
  return response.json.success('success', { rows: results })
145
145
  },
146
- (error: any) => response.json.error(400, error?.message ?? 'Lookup failed'),
146
+ error => refuse('lookup', error),
147
147
  )
148
148
  }
@@ -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
 
@@ -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
  }
@@ -15,6 +15,7 @@ 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
@@ -112,6 +113,32 @@ export async function handleSchema(): Promise<JsonResponseData<unknown>> {
112
113
  )
113
114
  }
114
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
+
115
142
  /** Table names the way the ORM writes them: identifier characters only. */
116
143
  const RX_TABLE_NAME = /^[a-zA-Z0-9_]+$/
117
144
 
@@ -161,15 +188,46 @@ export async function handleTableData(
161
188
 
162
189
  return await Try.return(
163
190
  async () => {
191
+ const page = readBounded(url, 'page', 1, 1, Number.MAX_SAFE_INTEGER)
164
192
  const data = await connection.getData(tableName, {
165
- page: Number.parseInt(url.searchParams.get('page') || '1', 10),
166
- pageSize: Number.parseInt(url.searchParams.get('pageSize') || '50', 10),
193
+ page,
194
+ pageSize: readBounded(url, 'pageSize', 50, 1, MAX_PAGE_SIZE),
167
195
  sortBy: url.searchParams.get('sortBy'),
168
196
  sortOrder: url.searchParams.get('sortOrder') || 'ASC',
169
197
  filters: filters.filters,
198
+ knownTotal: readKnownTotal(url, page),
170
199
  })
171
200
  return response.json.success('success', data)
172
201
  },
173
- (error: any) => response.json.error(400, error.message),
202
+ error => refuse('table-data', error),
174
203
  )
175
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
+ }
@@ -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
@@ -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
@@ -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
 
@@ -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(
package/src/identity.ts CHANGED
@@ -311,15 +311,54 @@ export function metaOf(
311
311
  }
312
312
  }
313
313
 
314
+ /**
315
+ * One entry, held only while the schema it describes is still the live one.
316
+ *
317
+ * Convention 6 forbids an unbounded module cache; this is a single slot, so it
318
+ * is bounded by construction. What made a cache unsafe here was never its size
319
+ * - the note this replaces said so, and it was right at the time: "the wrong
320
+ * entry is not a slow response, it is a write addressed by a key the table no
321
+ * longer has." Staleness, not memory.
322
+ *
323
+ * `schemaFingerprint()` closes exactly that. The entry is used only when the
324
+ * adapter reports the same schema version it was built under, so an entry
325
+ * describing a dropped column cannot be handed to a write - the version moved
326
+ * when the column was dropped.
327
+ *
328
+ * Keyed on the whole fingerprint string rather than a bare counter, because
329
+ * the adapter prefixes it with the driver and the file: two SQLite databases
330
+ * both at version 3 must not share an entry.
331
+ */
332
+ let cached: {
333
+ fingerprint: string
334
+ facts: Map<string, TableFacts>
335
+ } | null = null
336
+
337
+ /**
338
+ * Test seam (convention 9). A cache with no way to clear it makes every test
339
+ * after the first one depend on what the first one did.
340
+ */
341
+ export function __resetIntrospectCache() {
342
+ cached = null
343
+ }
344
+
314
345
  /**
315
346
  * Every table the connection has, with its identity resolved.
316
347
  *
317
- * Three round trips, taken per request rather than cached. Convention 6 forbids
318
- * an unbounded module cache, and a bounded one would be worse than none here:
319
- * the wrong entry is not a slow response, it is a write addressed by a key the
320
- * table no longer has. Schema introspection is also exactly what the explorer's
321
- * own `/api/_db/schema` call already costs, so a write pays what a page load
322
- * pays.
348
+ * Three round trips, and for a 50-table SQLite database that is **250
349
+ * statements and 8.28 ms** - paid by every write, every foreign-key hover and
350
+ * the graph endpoint. On a network dialect the same shape is roughly 52
351
+ * sequential round trips per write.
352
+ *
353
+ * Asking whether it is still valid is 10.4 us, so the cache pays for itself
354
+ * 795 times over on a hit and costs one extra statement on a miss.
355
+ *
356
+ * **Row counts are never cached**, and that is the load-bearing exception.
357
+ * `schema_version` does not move for an `INSERT` - which is exactly what makes
358
+ * it a good key for schema, and exactly what makes it a wrong key for a
359
+ * `COUNT(*)`. A cached count would be silently wrong for as long as nobody
360
+ * changed the schema. Asking for counts skips the cache in both directions:
361
+ * it neither reads an entry nor writes one.
323
362
  */
324
363
  export async function introspect(options?: {
325
364
  /**
@@ -329,8 +368,25 @@ export async function introspect(options?: {
329
368
  */
330
369
  rowCounts?: boolean
331
370
  }): Promise<Map<string, TableFacts>> {
371
+ const wantsCounts = options?.rowCounts === true
372
+
373
+ // `null` from an adapter that cannot answer cheaply - Postgres and MySQL
374
+ // today - and the behaviour is then exactly what it was before this cache
375
+ // existed. Correctness does not depend on the capability being present.
376
+ //
377
+ // Probed rather than called, because this plugin is published against a
378
+ // *range* of `@bakery-framework/orm` versions and an adapter can come from a
379
+ // third-party package that predates the method. A `TypeError` here would
380
+ // turn a missing optimisation into a failed request; the tests' own stub
381
+ // adapters are the same shape and proved the point immediately.
382
+ const fingerprint =
383
+ wantsCounts || typeof connection.schemaFingerprint !== 'function'
384
+ ? null
385
+ : await connection.schemaFingerprint()
386
+ if (fingerprint && cached?.fingerprint === fingerprint) return cached.facts
387
+
332
388
  const [schema, constraints, indexes] = await Promise.all([
333
- connection.getSchema({ rowCounts: options?.rowCounts === true }),
389
+ connection.getSchema({ rowCounts: wantsCounts }),
334
390
  connection.getConstraints(),
335
391
  connection.getIndexes(),
336
392
  ])
@@ -397,5 +453,9 @@ export async function introspect(options?: {
397
453
  })
398
454
  }
399
455
 
456
+ // Stored after the walk rather than before it, so a walk that throws leaves
457
+ // no entry claiming to describe a schema nobody successfully read.
458
+ if (fingerprint) cached = { fingerprint, facts: byTable }
459
+
400
460
  return byTable
401
461
  }
package/src/setup.ts CHANGED
@@ -6,7 +6,7 @@ import type { PluginRouteTable } from '@bakery-framework/core/plugins'
6
6
  import { routeTable } from '@bakery-framework/core/plugins'
7
7
  import { fs } from '@bakery-framework/core/utils'
8
8
  import { response } from '@bakery-framework/core/utils/http'
9
- import { type AccessConfig, accessStore, resolveAccess } from './access'
9
+ import { accessStore, assertValidUsers, resolveAccess, type AccessConfig } from './access'
10
10
  import { handleGraph, handleLookup } from './endpoints/graph'
11
11
  import { handleImport } from './endpoints/import'
12
12
  import { handleSchema, handleTableData } from './endpoints/read'
@@ -139,6 +139,10 @@ export class DbExplorerHandler extends Handler {
139
139
  }
140
140
 
141
141
  export function setupExplorer(options: AccessConfig = {}) {
142
+ // Before anything is registered: a bad level cannot become good later, and a
143
+ // server that boots while refusing the users it was configured with is worse
144
+ // than one that refuses to boot.
145
+ assertValidUsers(options.users)
142
146
  config = options
143
147
  // Above the content handlers, below nothing that matters: the /_db and
144
148
  // /api/_db namespaces are reserved for framework routes (convention 10),