@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.
- package/package.json +5 -5
- package/src/access.ts +53 -17
- package/src/client/api.ts +48 -6
- package/src/client/bulk.ts +31 -7
- package/src/client/cell.ts +5 -5
- package/src/client/confirm.ts +11 -12
- package/src/client/csv-commit.ts +25 -5
- package/src/client/csv-map.ts +7 -7
- package/src/client/csv-model.ts +61 -8
- package/src/client/csv-pick.ts +1 -1
- package/src/client/csv-preview.ts +2 -2
- package/src/client/csv.ts +2 -2
- package/src/client/dom.ts +3 -5
- package/src/client/edit-session.ts +7 -7
- package/src/client/editors.ts +5 -5
- package/src/client/filter-builder.ts +6 -6
- package/src/client/fk.ts +9 -9
- package/src/client/grid-body.ts +4 -4
- package/src/client/grid-header.ts +2 -2
- package/src/client/grid-rowbar.ts +1 -1
- package/src/client/grid.ts +8 -9
- package/src/client/meta.ts +6 -6
- package/src/client/page.ts +4 -4
- package/src/client/panel.ts +8 -8
- package/src/client/relations.ts +4 -4
- package/src/client/save.ts +4 -4
- package/src/client/sidebar.ts +6 -6
- package/src/client/state.ts +4 -4
- package/src/client/statusbar.ts +6 -6
- package/src/client/structure.ts +8 -8
- package/src/client/tabs.ts +10 -10
- package/src/client/tabstrip.ts +3 -3
- package/src/client.ts +17 -17
- package/src/endpoints/common.ts +25 -6
- package/src/endpoints/graph.ts +5 -5
- package/src/endpoints/import.ts +5 -5
- package/src/endpoints/read.ts +70 -10
- package/src/endpoints/rows.ts +219 -14
- package/src/identity.ts +122 -52
- package/src/index.ts +1 -1
- package/src/policy.ts +4 -4
- package/src/preview.ts +5 -5
- package/src/setup.ts +16 -10
- package/src/shared/coerce.ts +21 -22
- package/src/shared/csv.ts +5 -5
- package/src/shared/filters.ts +6 -6
- package/src/shared/plan.ts +7 -7
- package/src/shell.ts +3 -3
- package/src/validate.ts +10 -10
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bakery-framework/plugin-db-explorer",
|
|
3
|
-
"version": "2.0.0-
|
|
4
|
-
"description": "Bakery database explorer plugin
|
|
3
|
+
"version": "2.0.0-rc.2",
|
|
4
|
+
"description": "Bakery database explorer plugin: browse and edit rows, no raw SQL, no DDL.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"bakery",
|
|
7
7
|
"bun",
|
|
@@ -33,10 +33,10 @@
|
|
|
33
33
|
"!src/tests"
|
|
34
34
|
],
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"@bakery-framework/core": "^2.0.0-
|
|
37
|
-
"@bakery-framework/orm": "^2.0.0-
|
|
36
|
+
"@bakery-framework/core": "^2.0.0-rc.2",
|
|
37
|
+
"@bakery-framework/orm": "^2.0.0-rc.2"
|
|
38
38
|
},
|
|
39
39
|
"engines": {
|
|
40
|
-
"bun": ">=1.
|
|
40
|
+
"bun": ">=1.4.0"
|
|
41
41
|
}
|
|
42
42
|
}
|
package/src/access.ts
CHANGED
|
@@ -9,20 +9,20 @@ import {
|
|
|
9
9
|
*
|
|
10
10
|
* The explorer used to be read-only by construction, so a boolean answered the
|
|
11
11
|
* whole question. It edits rows now, and "may this request in" and "may this
|
|
12
|
-
* request *write*" are different questions
|
|
12
|
+
* request *write*" are different questions, so the predicate returns a level
|
|
13
13
|
* rather than a yes.
|
|
14
14
|
*
|
|
15
15
|
* Two doors, and an application may use either or both:
|
|
16
16
|
*
|
|
17
|
-
* - `users
|
|
17
|
+
* - `users`: named credentials, for people and scripts that have no session
|
|
18
18
|
* (an on-call engineer with a key, a seeding job).
|
|
19
|
-
* - `authorize
|
|
19
|
+
* - `authorize`: a predicate over the request, for applications that already
|
|
20
20
|
* know who their users are and would rather not keep a second list.
|
|
21
21
|
*
|
|
22
22
|
* Either can admit and the **higher** level wins, because they answer about the
|
|
23
23
|
* same caller: a session admin presenting a read-only key is still an admin.
|
|
24
24
|
*
|
|
25
|
-
* Everything fails closed. Nothing configured admits nobody
|
|
25
|
+
* Everything fails closed. Nothing configured admits nobody: the same default
|
|
26
26
|
* the read-only explorer had, and the reason there is no `writes: true` flag to
|
|
27
27
|
* leave set by accident.
|
|
28
28
|
*
|
|
@@ -37,7 +37,7 @@ export type Access = 'read' | 'write'
|
|
|
37
37
|
/**
|
|
38
38
|
* An application's own access check. Return `'write'`, `'read'`, or `false`.
|
|
39
39
|
*
|
|
40
|
-
* Anything else
|
|
40
|
+
* Anything else (including `true`) is a denial. The predicate is application
|
|
41
41
|
* code and this return type is only advice; an untyped or transpiled one can
|
|
42
42
|
* hand back anything, and admission is the expensive direction to get wrong.
|
|
43
43
|
*/
|
|
@@ -71,14 +71,14 @@ function higher(a: Access | false, b: Access | false): Access | false {
|
|
|
71
71
|
/**
|
|
72
72
|
* The credential this request presents, or `null`.
|
|
73
73
|
*
|
|
74
|
-
* Core's `readCredential` accepts three forms
|
|
75
|
-
* `?db-key
|
|
74
|
+
* Core's `readCredential` accepts three forms: `x-db-key`, a Bearer token, and
|
|
75
|
+
* `?db-key=`, and the query form is deliberate there: a human opening a URL
|
|
76
76
|
* cannot set a header.
|
|
77
77
|
*
|
|
78
78
|
* **On a state-changing request the query form is refused.** A credential in a
|
|
79
79
|
* URL is what makes a cross-site write possible: the browser will send it
|
|
80
|
-
* because it is in the link, and `checkCsrf`
|
|
81
|
-
* a token
|
|
80
|
+
* because it is in the link, and `checkCsrf` (which is an `Origin` check, not
|
|
81
|
+
* a token) passes when `Origin` is absent or literally `"null"`, as it is from
|
|
82
82
|
* a sandboxed iframe or some redirect chains. Requiring a header for writes
|
|
83
83
|
* means the caller had to run script on this origin.
|
|
84
84
|
*/
|
|
@@ -87,10 +87,17 @@ function presentedKey(req: Request): string | null {
|
|
|
87
87
|
if (presented === null) return null
|
|
88
88
|
if (SAFE_METHODS.has(req.method)) return presented
|
|
89
89
|
|
|
90
|
-
// `readCredential` prefers header, then Bearer, then the query
|
|
91
|
-
// neither header
|
|
92
|
-
|
|
93
|
-
|
|
90
|
+
// `readCredential` prefers header, then Bearer, then the query, so if
|
|
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
|
|
|
@@ -99,7 +106,7 @@ function presentedKey(req: Request): string | null {
|
|
|
99
106
|
*
|
|
100
107
|
* **Every entry is compared, with no early exit.** Each comparison is constant
|
|
101
108
|
* time (core's `credentialMatches`), and stopping at the first match would make
|
|
102
|
-
* the response time depend on where in the map the matching key sits
|
|
109
|
+
* the response time depend on where in the map the matching key sits, which
|
|
103
110
|
* leaks the position of a valid key over enough samples.
|
|
104
111
|
*/
|
|
105
112
|
export function accessFromUsers(
|
|
@@ -151,7 +158,7 @@ export async function resolveAccess(
|
|
|
151
158
|
req: Request,
|
|
152
159
|
config: AccessConfig,
|
|
153
160
|
): Promise<Access | false> {
|
|
154
|
-
// Both doors are consulted even when the first admits
|
|
161
|
+
// Both doors are consulted even when the first admits: the predicate may
|
|
155
162
|
// grant `write` where a key granted `read`, and the caller is one person.
|
|
156
163
|
const fromKey = accessFromUsers(req, config.users)
|
|
157
164
|
const fromPredicate = await accessFromPredicate(req, config.authorize)
|
|
@@ -170,8 +177,8 @@ export function canWrite(access: Access | false): boolean {
|
|
|
170
177
|
* `hostStore` is one in core: a module variable is shared by every in-flight
|
|
171
178
|
* request, so under any concurrency at all one caller's level would decide
|
|
172
179
|
* another caller's write. It is also cheaper than re-resolving per endpoint,
|
|
173
|
-
* which would run an application's `authorize` predicate
|
|
174
|
-
* lookup
|
|
180
|
+
* which would run an application's `authorize` predicate (possibly a session
|
|
181
|
+
* lookup) twice for one request.
|
|
175
182
|
*
|
|
176
183
|
* Outside a request it is `false`, which is the safe answer rather than a
|
|
177
184
|
* crash: an endpoint reached some other way has no caller to grant anything.
|
|
@@ -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
|
@@ -34,14 +34,14 @@ export class ApiError extends Error {
|
|
|
34
34
|
* What to show a user when a call threw.
|
|
35
35
|
*
|
|
36
36
|
* Here rather than in `dom.ts` because the thing it knows about is `ApiError`,
|
|
37
|
-
* whose `message` is the server's own sentence from the envelope
|
|
37
|
+
* whose `message` is the server's own sentence from the envelope: the reason
|
|
38
38
|
* a failed write reads as "row was modified by someone else" instead of
|
|
39
39
|
* "Request failed (409)". `String(error)` is the fallback for the two throws
|
|
40
40
|
* that are not ours: an abort and a network failure.
|
|
41
41
|
*
|
|
42
|
-
* There used to be three of these
|
|
42
|
+
* There used to be three of these: one exported from `bulk.ts`, a private
|
|
43
43
|
* near-copy in `save.ts`, and a third inlined at the `notify` call in
|
|
44
|
-
* `csv-commit.ts
|
|
44
|
+
* `csv-commit.ts`, so improving the wording in one left the other two alone.
|
|
45
45
|
*/
|
|
46
46
|
export function messageOf(error: unknown): string {
|
|
47
47
|
const api = error as Partial<ApiError>
|
|
@@ -91,7 +91,7 @@ interface Envelope {
|
|
|
91
91
|
/**
|
|
92
92
|
* The data, plus what the server said it cost.
|
|
93
93
|
*
|
|
94
|
-
* `time` is the envelope's own field
|
|
94
|
+
* `time` is the envelope's own field: `router.ts` fills it from `getElapsed`,
|
|
95
95
|
* so it is the server's measurement of its own work rather than a round trip
|
|
96
96
|
* timed in the browser. The status bar shows it, which is the one honest number
|
|
97
97
|
* available for "why is this slow": a filter that cannot use an index shows up
|
|
@@ -159,7 +159,7 @@ export async function fetchGraph(): Promise<SchemaGraph> {
|
|
|
159
159
|
* One page of a table, with the server's own timing.
|
|
160
160
|
*
|
|
161
161
|
* **`filters` carries an operator now.** It used to be a bare column→substring
|
|
162
|
-
* record, which meant `col LIKE '%value%'` and nothing else
|
|
162
|
+
* record, which meant `col LIKE '%value%'` and nothing else, so a filter could
|
|
163
163
|
* narrow a page but could never *name* a row, `1` matching `11` and `21`. With
|
|
164
164
|
* `eq` in the vocabulary it can, which is what removed `ViewState.focus` and
|
|
165
165
|
* turned a foreign-key jump into an ordinary filter.
|
|
@@ -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
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* **Every count that reaches a dialog comes from a `dryRun`.** The server
|
|
5
5
|
* executes the statements inside a transaction and rolls back, so the number is
|
|
6
|
-
* what would actually happen
|
|
6
|
+
* what would actually happen, not the number of checkboxes, which is the same
|
|
7
7
|
* number only when nothing has changed underneath and no key has gone missing.
|
|
8
8
|
* `confirm.ts` turns that count into the right amount of ceremony.
|
|
9
9
|
*/
|
|
@@ -69,7 +69,7 @@ export function bulkToolbar(ctx: BulkContext): BulkToolbar {
|
|
|
69
69
|
* One column, one value, across the selection.
|
|
70
70
|
*
|
|
71
71
|
* The value goes through the same editor the grid uses, so an enum is a select
|
|
72
|
-
* and a nullable column can be set to NULL
|
|
72
|
+
* and a nullable column can be set to NULL: the operation people actually
|
|
73
73
|
* reach for and the one a plain text prompt cannot express.
|
|
74
74
|
*/
|
|
75
75
|
async function openSetColumn(ctx: BulkContext): Promise<void> {
|
|
@@ -97,7 +97,7 @@ async function openSetColumn(ctx: BulkContext): Promise<void> {
|
|
|
97
97
|
}
|
|
98
98
|
|
|
99
99
|
const picker = select(
|
|
100
|
-
editable.map(c => ({ value: c.name, label: `${c.name}
|
|
100
|
+
editable.map(c => ({ value: c.name, label: `${c.name}: ${c.type}` })),
|
|
101
101
|
column.name,
|
|
102
102
|
name => {
|
|
103
103
|
column = editable.find(c => c.name === name) ?? column
|
|
@@ -199,7 +199,7 @@ async function runDelete(ctx: BulkContext): Promise<void> {
|
|
|
199
199
|
/**
|
|
200
200
|
* A ten-second offer to put the row back.
|
|
201
201
|
*
|
|
202
|
-
* Only for the immediate tier
|
|
202
|
+
* Only for the immediate tier: anything above one row went through a dialog,
|
|
203
203
|
* and re-inserting a hundred rows from the browser's memory is a second bulk
|
|
204
204
|
* write dressed up as a safety net. The pre-image is already in hand because
|
|
205
205
|
* the grid holds the rows it rendered.
|
|
@@ -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] })
|
|
@@ -230,7 +230,7 @@ function offerDeleteUndo(
|
|
|
230
230
|
* Add rows by hand.
|
|
231
231
|
*
|
|
232
232
|
* A column is sent only when the user gave it a value or when the database
|
|
233
|
-
* cannot supply one
|
|
233
|
+
* cannot supply one: `omittableOnInsert` is the same predicate the importer's
|
|
234
234
|
* blocking check uses. Sending every column would defeat every default in the
|
|
235
235
|
* schema and would make an auto-increment key impossible to leave alone.
|
|
236
236
|
*/
|
|
@@ -344,7 +344,7 @@ function buildInsertRows(
|
|
|
344
344
|
* A rejected promise from an event listener is an unhandled rejection and a
|
|
345
345
|
* silent no-op on screen, which is the one outcome a destructive action must
|
|
346
346
|
* not have. `null` is the failure, so a dry run's caller checks the result and
|
|
347
|
-
* an action's caller ignores it
|
|
347
|
+
* an action's caller ignores it: they were two identically-bodied functions
|
|
348
348
|
* until the second was noticed to be the first with the value discarded.
|
|
349
349
|
*/
|
|
350
350
|
async function run<T>(call: () => Promise<T>): Promise<T | null> {
|
|
@@ -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/cell.ts
CHANGED
|
@@ -4,9 +4,9 @@
|
|
|
4
4
|
* **Pure, and the reason it exists apart from the grid.** A key handler that
|
|
5
5
|
* reads the DOM to decide what to do can only be tested by building a DOM;
|
|
6
6
|
* this one takes a plain `{key, shiftKey}` and a plain cursor and answers with
|
|
7
|
-
* an action, so `cell.test.ts` pins the whole interaction model
|
|
7
|
+
* an action, so `cell.test.ts` pins the whole interaction model, including the
|
|
8
8
|
* two rules that are easy to regress, *Enter commits and moves down* and
|
|
9
|
-
* *blur is not in this table at all
|
|
9
|
+
* *blur is not in this table at all*, without happy-dom.
|
|
10
10
|
*
|
|
11
11
|
* **Blur is deliberately absent.** The dashboard saves on blur, so a stray
|
|
12
12
|
* click into another cell is a write, and a three-column edit is three
|
|
@@ -45,7 +45,7 @@ export type CellAction =
|
|
|
45
45
|
| { type: 'commit'; move: 'down' | 'right' | 'left' | 'none' }
|
|
46
46
|
/** Throw the editor's value away and restore the cell. */
|
|
47
47
|
| { type: 'cancel' }
|
|
48
|
-
/** Stage SQL NULL
|
|
48
|
+
/** Stage SQL NULL: distinct from staging the empty string. */
|
|
49
49
|
| { type: 'null' }
|
|
50
50
|
|
|
51
51
|
const NONE: CellAction = { type: 'none' }
|
|
@@ -78,14 +78,14 @@ function moveTo(state: CellState, row: number, col: number): CellAction {
|
|
|
78
78
|
return { type: 'move', ...next }
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
-
/** Only when the table is editable
|
|
81
|
+
/** Only when the table is editable: a read-only grid still navigates. */
|
|
82
82
|
function whenEditable(action: CellAction): Rule {
|
|
83
83
|
return (_event, state) => (state.editable ? action : NONE)
|
|
84
84
|
}
|
|
85
85
|
|
|
86
86
|
/**
|
|
87
87
|
* While an editor is open. Four keys, and everything else belongs to the input
|
|
88
|
-
|
|
88
|
+
*: a `default` that swallowed keys here would make the editor unable to
|
|
89
89
|
* receive a literal `a`.
|
|
90
90
|
*/
|
|
91
91
|
const EDITING: Record<string, Rule> = {
|
package/src/client/confirm.ts
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
* ZONE.
|
|
4
4
|
*
|
|
5
5
|
* `frictionFor` is **pure and tested**; the dialog below is the DOM half. The
|
|
6
|
-
* split matters because the ladder is policy
|
|
7
|
-
* "how bad is this if it was a mis-click"
|
|
6
|
+
* split matters because the ladder is policy (the thresholds are the answer to
|
|
7
|
+
* "how bad is this if it was a mis-click"), and policy that lives inside a
|
|
8
8
|
* click handler cannot be asserted.
|
|
9
9
|
*
|
|
10
10
|
* **The count fed to this must come from a `dryRun`, never from the page.** A
|
|
@@ -20,12 +20,12 @@ export type Friction = 'immediate' | 'confirm' | 'typed' | 'refuse'
|
|
|
20
20
|
/**
|
|
21
21
|
* How much ceremony `count` rows deserve.
|
|
22
22
|
*
|
|
23
|
-
* - **≤ 1
|
|
23
|
+
* - **≤ 1**: immediate, with an undo. One row is recoverable and a dialog per
|
|
24
24
|
* row makes the tool unusable for the thing it is for.
|
|
25
|
-
* - **2
|
|
26
|
-
* - **101
|
|
25
|
+
* - **2 to 100**: a dialog naming the count and what changes.
|
|
26
|
+
* - **101 to 10 000**: the same, plus typing the table name. At this size the
|
|
27
27
|
* user is doing something deliberate and should have to prove it.
|
|
28
|
-
* - **> 10 000
|
|
28
|
+
* - **> 10 000**, refused. Not a dialog: there is no phrasing of "are you
|
|
29
29
|
* sure" that makes a ten-thousand-row unreviewed write a good idea, and the
|
|
30
30
|
* honest answer is to narrow it with a filter.
|
|
31
31
|
*/
|
|
@@ -56,7 +56,7 @@ export interface DangerOutcome {
|
|
|
56
56
|
* Ask, at the level `frictionFor` decided.
|
|
57
57
|
*
|
|
58
58
|
* Returns rather than throws, and returns `{ok: false}` for a dismissal and for
|
|
59
|
-
* a refusal alike
|
|
59
|
+
* a refusal alike. The caller does one check. The refusal carries its reason
|
|
60
60
|
* so the message names the ceiling instead of failing silently.
|
|
61
61
|
*/
|
|
62
62
|
export async function confirmDanger(
|
|
@@ -69,7 +69,7 @@ export async function confirmDanger(
|
|
|
69
69
|
ok: false,
|
|
70
70
|
refusal:
|
|
71
71
|
`${request.count} rows is past the ${(10_000).toLocaleString()} row ` +
|
|
72
|
-
'ceiling for one action
|
|
72
|
+
'ceiling for one action: narrow it with a filter',
|
|
73
73
|
}
|
|
74
74
|
}
|
|
75
75
|
const ok = await openDialog(request, level === 'typed')
|
|
@@ -81,7 +81,7 @@ export async function confirmDanger(
|
|
|
81
81
|
*
|
|
82
82
|
* For the questions that are not about blast radius. Discarding one row of
|
|
83
83
|
* unsaved typing deserves a prompt even though `frictionFor(1)` is `immediate`
|
|
84
|
-
|
|
84
|
+
*: the ladder is about how much damage a mis-click does to the *database*,
|
|
85
85
|
* and this one is about work the user has done and the database has not seen.
|
|
86
86
|
*/
|
|
87
87
|
export async function confirmChoice(request: DangerRequest): Promise<boolean> {
|
|
@@ -93,8 +93,7 @@ export async function confirmChoice(request: DangerRequest): Promise<boolean> {
|
|
|
93
93
|
*
|
|
94
94
|
* The native element rather than a hand-rolled overlay: it traps focus, closes
|
|
95
95
|
* on Escape, and is inert to the page behind it without a single line of
|
|
96
|
-
* script. Resolved through a `close` listener so a dismissal by any route
|
|
97
|
-
* Escape, the backdrop, the button — lands in one place.
|
|
96
|
+
* script. Resolved through a `close` listener so a dismissal by any route ( * Escape, the backdrop, the button) lands in one place.
|
|
98
97
|
*/
|
|
99
98
|
function openDialog(request: DangerRequest, typed: boolean): Promise<boolean> {
|
|
100
99
|
const dialog = el('dialog', { class: 'danger' })
|
|
@@ -141,7 +140,7 @@ function openDialog(request: DangerRequest, typed: boolean): Promise<boolean> {
|
|
|
141
140
|
|
|
142
141
|
// `settle` is narrowed to `(value: boolean) => void` rather than used as the
|
|
143
142
|
// executor's own `resolve`, whose parameter is `boolean | PromiseLike<boolean>`
|
|
144
|
-
|
|
143
|
+
//. Calling that reads as an unhandled promise to the floating-promise rule.
|
|
145
144
|
let settle: (value: boolean) => void = () => {}
|
|
146
145
|
const answered = new Promise<boolean>(resolve => {
|
|
147
146
|
settle = resolve
|
package/src/client/csv-commit.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* The last step: the bad-row policy, and sending.
|
|
3
3
|
*
|
|
4
4
|
* The interesting part is `commit`. It chunks, and the Cancel button stops
|
|
5
|
-
* **before** the next request rather than aborting one in flight
|
|
5
|
+
* **before** the next request rather than aborting one in flight: canceling
|
|
6
6
|
* mid-flight would leave the user unable to say what landed, and this way the
|
|
7
7
|
* answer is exact and is reported: the chunks that completed are the rows that
|
|
8
8
|
* are in.
|
|
@@ -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, authorization 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: canceling 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
|
|
@@ -107,7 +127,7 @@ async function commit(
|
|
|
107
127
|
append(stage, [progress, box('row-bar', stop)])
|
|
108
128
|
|
|
109
129
|
// All-or-nothing means one transaction, and one transaction means one
|
|
110
|
-
// request
|
|
130
|
+
// request: chunking it would produce N transactions and exactly the partial
|
|
111
131
|
// apply the option exists to rule out.
|
|
112
132
|
const batches = model.onBadRow === 'all' ? [records] : chunk(records, CHUNK)
|
|
113
133
|
const onBadRow = model.onBadRow === 'skip' ? 'skip' : 'stop'
|
|
@@ -155,7 +175,7 @@ function paintDone(
|
|
|
155
175
|
append(stage, [
|
|
156
176
|
el('p', {
|
|
157
177
|
text: facts.cancelled
|
|
158
|
-
? `Cancelled
|
|
178
|
+
? `Cancelled: ${facts.inserted} rows landed before it stopped.`
|
|
159
179
|
: `${facts.inserted} rows imported.`,
|
|
160
180
|
}),
|
|
161
181
|
failures.length ? rejectedDownload(model, failures) : null,
|
package/src/client/csv-map.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Steps two and three: how the file was parsed, and where each column goes.
|
|
3
3
|
*
|
|
4
|
-
* Nothing here inspects a `<select>` to work out what the mapping is
|
|
4
|
+
* Nothing here inspects a `<select>` to work out what the mapping is: the
|
|
5
5
|
* mapping *is* the model, and every control writes a whole new one through
|
|
6
6
|
* `update`. `csv-model.ts` holds all of it and is pure.
|
|
7
7
|
*/
|
|
@@ -32,7 +32,7 @@ export interface Reparse {
|
|
|
32
32
|
/**
|
|
33
33
|
* Delimiter and header, both sniffed and both overridable.
|
|
34
34
|
*
|
|
35
|
-
* Re-parsing needs the original text, which the model does not keep
|
|
35
|
+
* Re-parsing needs the original text, which the model does not keep, so the
|
|
36
36
|
* source is rebuilt from the parsed rows. That is lossless for the purpose:
|
|
37
37
|
* changing the delimiter after the fact re-splits fields that the wrong
|
|
38
38
|
* delimiter merged, and the merged text is exactly what was in the file.
|
|
@@ -134,8 +134,8 @@ export function paintMapping(
|
|
|
134
134
|
/**
|
|
135
135
|
* One CSV column: its name, three samples, and where it goes.
|
|
136
136
|
*
|
|
137
|
-
* Re-picking a database column already taken **moves** it
|
|
138
|
-
* the previous holder
|
|
137
|
+
* Re-picking a database column already taken **moves** it (`reassign` clears
|
|
138
|
+
* the previous holder), so a duplicate mapping cannot be expressed here at all,
|
|
139
139
|
* rather than being flagged after the fact.
|
|
140
140
|
*/
|
|
141
141
|
function mappingRow(
|
|
@@ -148,8 +148,8 @@ function mappingRow(
|
|
|
148
148
|
const row = box('map-row')
|
|
149
149
|
|
|
150
150
|
const options = [
|
|
151
|
-
{ value: SKIP_VALUE, label: '
|
|
152
|
-
{ value: CONST_VALUE, label: '
|
|
151
|
+
{ value: SKIP_VALUE, label: '(skip)' },
|
|
152
|
+
{ value: CONST_VALUE, label: '(constant…)' },
|
|
153
153
|
...ctx.columns.map(column => ({
|
|
154
154
|
value: column.name,
|
|
155
155
|
label: `${column.name} · ${column.type}`,
|
|
@@ -200,7 +200,7 @@ function constantControls(
|
|
|
200
200
|
): HTMLElement[] {
|
|
201
201
|
const target = select(
|
|
202
202
|
[
|
|
203
|
-
{ value: SKIP_VALUE, label: '
|
|
203
|
+
{ value: SKIP_VALUE, label: '(into which column)' },
|
|
204
204
|
...ctx.columns.map(column => ({
|
|
205
205
|
value: column.name,
|
|
206
206
|
label: column.name,
|