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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/package.json +5 -5
  2. package/src/access.ts +53 -17
  3. package/src/client/api.ts +48 -6
  4. package/src/client/bulk.ts +31 -7
  5. package/src/client/cell.ts +5 -5
  6. package/src/client/confirm.ts +11 -12
  7. package/src/client/csv-commit.ts +25 -5
  8. package/src/client/csv-map.ts +7 -7
  9. package/src/client/csv-model.ts +61 -8
  10. package/src/client/csv-pick.ts +1 -1
  11. package/src/client/csv-preview.ts +2 -2
  12. package/src/client/csv.ts +2 -2
  13. package/src/client/dom.ts +3 -5
  14. package/src/client/edit-session.ts +7 -7
  15. package/src/client/editors.ts +5 -5
  16. package/src/client/filter-builder.ts +6 -6
  17. package/src/client/fk.ts +9 -9
  18. package/src/client/grid-body.ts +4 -4
  19. package/src/client/grid-header.ts +2 -2
  20. package/src/client/grid-rowbar.ts +1 -1
  21. package/src/client/grid.ts +8 -9
  22. package/src/client/meta.ts +6 -6
  23. package/src/client/page.ts +4 -4
  24. package/src/client/panel.ts +8 -8
  25. package/src/client/relations.ts +4 -4
  26. package/src/client/save.ts +4 -4
  27. package/src/client/sidebar.ts +6 -6
  28. package/src/client/state.ts +4 -4
  29. package/src/client/statusbar.ts +6 -6
  30. package/src/client/structure.ts +8 -8
  31. package/src/client/tabs.ts +10 -10
  32. package/src/client/tabstrip.ts +3 -3
  33. package/src/client.ts +17 -17
  34. package/src/endpoints/common.ts +25 -6
  35. package/src/endpoints/graph.ts +5 -5
  36. package/src/endpoints/import.ts +5 -5
  37. package/src/endpoints/read.ts +70 -10
  38. package/src/endpoints/rows.ts +219 -14
  39. package/src/identity.ts +122 -52
  40. package/src/index.ts +1 -1
  41. package/src/policy.ts +4 -4
  42. package/src/preview.ts +5 -5
  43. package/src/setup.ts +16 -10
  44. package/src/shared/coerce.ts +21 -22
  45. package/src/shared/csv.ts +5 -5
  46. package/src/shared/filters.ts +6 -6
  47. package/src/shared/plan.ts +7 -7
  48. package/src/shell.ts +3 -3
  49. package/src/validate.ts +10 -10
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@bakery-framework/plugin-db-explorer",
3
- "version": "2.0.0-alpha.9",
4
- "description": "Bakery database explorer plugin — browse and edit rows — no raw SQL, no DDL.",
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-alpha.9",
37
- "@bakery-framework/orm": "^2.0.0-alpha.9"
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.3.14"
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 — so the predicate returns a level
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` — named credentials, for people and scripts that have no session
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` — a predicate over the request, for applications that already
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 — the same default
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 — including `true` — is a denial. The predicate is application
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 — `x-db-key`, a Bearer token, and
75
- * `?db-key=` — and the query form is deliberate there: a human opening a URL
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` — which is an `Origin` check, not
81
- * a token — passes when `Origin` is absent or literally `"null"`, as it is from
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 — 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')
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 — which
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 — the predicate may
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 — possibly a session
174
- * lookup — twice for one request.
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 — the reason
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 — one exported from `bulk.ts`, a private
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` — so improving the wording in one left the other two alone.
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 — `router.ts` fills it from `getElapsed`,
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 — so a filter could
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
- 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 {
@@ -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 — not the number of checkboxes, which is the same
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 — the operation people actually
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} — ${c.type}` })),
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 — anything above one row went through a dialog,
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 — `omittableOnInsert` is the same predicate the importer's
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 — they were two identically-bodied functions
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
+ }
@@ -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 — including the
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* — without happy-dom.
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 — distinct from staging the empty string. */
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 — a read-only grid still navigates. */
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
- * — a `default` that swallowed keys here would make the editor unable to
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> = {
@@ -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 — the thresholds are the answer to
7
- * "how bad is this if it was a mis-click" — and policy that lives inside a
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** — immediate, with an undo. One row is recoverable and a dialog per
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–100** — a dialog naming the count and what changes.
26
- * - **101–10 000** — the same, plus typing the table name. At this size the
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** — refused. Not a dialog: there is no phrasing of "are you
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 — the caller does one check. The refusal carries its reason
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 — narrow it with a filter',
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
- * — the ladder is about how much damage a mis-click does to the *database*,
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
- // — calling that reads as an unhandled promise to the floating-promise rule.
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
@@ -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 — cancelling
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
- /** 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, 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 — chunking it would produce N transactions and exactly the partial
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 — ${facts.inserted} rows landed before it stopped.`
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,
@@ -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 — the
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 — so the
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 — `reassign` clears
138
- * the previous holder — so a duplicate mapping cannot be expressed here at all,
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: '— skip —' },
152
- { value: CONST_VALUE, label: '— constant… —' },
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: '— into which column —' },
203
+ { value: SKIP_VALUE, label: '(into which column)' },
204
204
  ...ctx.columns.map(column => ({
205
205
  value: column.name,
206
206
  label: column.name,