@bakery-framework/plugin-db-explorer 2.0.0-alpha.12 → 2.0.0-alpha.14
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 +3 -3
- package/src/access.ts +39 -3
- package/src/client/api.ts +43 -1
- package/src/client/bulk.ts +25 -1
- package/src/client/csv-commit.ts +22 -2
- package/src/client/csv-model.ts +54 -1
- package/src/endpoints/common.ts +19 -0
- package/src/endpoints/graph.ts +2 -2
- package/src/endpoints/import.ts +2 -2
- package/src/endpoints/read.ts +61 -3
- package/src/endpoints/rows.ts +209 -4
- package/src/identity.ts +67 -7
- package/src/setup.ts +5 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bakery-framework/plugin-db-explorer",
|
|
3
|
-
"version": "2.0.0-alpha.
|
|
3
|
+
"version": "2.0.0-alpha.14",
|
|
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.
|
|
37
|
-
"@bakery-framework/orm": "^2.0.0-alpha.
|
|
36
|
+
"@bakery-framework/core": "^2.0.0-alpha.14",
|
|
37
|
+
"@bakery-framework/orm": "^2.0.0-alpha.14"
|
|
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
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
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 {
|
package/src/client/bulk.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/client/csv-commit.ts
CHANGED
|
@@ -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
|
-
/**
|
|
26
|
-
|
|
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
|
package/src/client/csv-model.ts
CHANGED
|
@@ -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
|
-
|
|
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. */
|
package/src/endpoints/common.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/endpoints/graph.ts
CHANGED
|
@@ -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
|
-
|
|
146
|
+
error => refuse('lookup', error),
|
|
147
147
|
)
|
|
148
148
|
}
|
package/src/endpoints/import.ts
CHANGED
|
@@ -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
|
|
86
|
+
return refuse('import', error)
|
|
87
87
|
},
|
|
88
88
|
)
|
|
89
89
|
}
|
package/src/endpoints/read.ts
CHANGED
|
@@ -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
|
|
166
|
-
pageSize:
|
|
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
|
-
|
|
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
|
+
}
|
package/src/endpoints/rows.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
318
|
-
*
|
|
319
|
-
* the
|
|
320
|
-
*
|
|
321
|
-
*
|
|
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:
|
|
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 {
|
|
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),
|