@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/src/client/csv-model.ts
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The import wizard's state, with no DOM anywhere in it.
|
|
3
3
|
*
|
|
4
|
-
* The wizard has five steps and the interesting
|
|
5
|
-
* transitions between them
|
|
4
|
+
* The wizard has five steps and the interesting behavior is all in the
|
|
5
|
+
* transitions between them: a re-pick *moves* a database column rather than
|
|
6
6
|
* duplicating it, an unmapped NOT NULL column blocks, `empty → NULL` defaults
|
|
7
7
|
* differently per kind, and the preview recomputes from the mapping every
|
|
8
8
|
* time. None of that is testable through a rendered `<select>`, so none of it
|
|
9
9
|
* lives there. `csv.ts` is the view; this is the machine.
|
|
10
10
|
*
|
|
11
|
-
* Parsing, coercion and mapping are **not reimplemented
|
|
11
|
+
* Parsing, coercion and mapping are **not reimplemented**: `shared/csv.ts`,
|
|
12
12
|
* `shared/coerce.ts` and `shared/plan.ts` are the same modules the server runs,
|
|
13
13
|
* which is the only way the preview and the outcome agree.
|
|
14
14
|
*/
|
|
@@ -58,7 +58,7 @@ export function targetOf(assignment: Assignment): string | null {
|
|
|
58
58
|
/**
|
|
59
59
|
* Does row 0 name the columns?
|
|
60
60
|
*
|
|
61
|
-
* Non-empty, unique, and not a number
|
|
61
|
+
* Non-empty, unique, and not a number: the three properties a header row has
|
|
62
62
|
* and a data row almost never does. Deliberately conservative in the direction
|
|
63
63
|
* of *yes*: guessing "header" for a data file loses one row in a preview the
|
|
64
64
|
* user is looking at, while guessing "data" for a header file maps nothing and
|
|
@@ -91,8 +91,8 @@ function square(fields: string[], width: number): string[] {
|
|
|
91
91
|
/**
|
|
92
92
|
* Empty means NULL by default for everything except text.
|
|
93
93
|
*
|
|
94
|
-
* `''` in a numeric or date column is never a value
|
|
95
|
-
* outright with `empty_string`
|
|
94
|
+
* `''` in a numeric or date column is never a value (`coerceValue` refuses it
|
|
95
|
+
* outright with `empty_string`), so defaulting the toggle off there guarantees
|
|
96
96
|
* a failure the user then has to diagnose. In a text column `''` *is* a value,
|
|
97
97
|
* and silently turning every blank cell into NULL would be the dashboard's bug
|
|
98
98
|
* with a checkbox on it.
|
|
@@ -161,7 +161,7 @@ export function buildModel(
|
|
|
161
161
|
*
|
|
162
162
|
* Any other header holding the same database column falls back to skip. That
|
|
163
163
|
* makes a duplicate mapping impossible to express, which is a stronger promise
|
|
164
|
-
* than `blockingIssues`' `duplicate_target
|
|
164
|
+
* than `blockingIssues`' `duplicate_target`: that check stays as the
|
|
165
165
|
* server-side guarantee, because the server accepts a mapping this UI did not
|
|
166
166
|
* build.
|
|
167
167
|
*/
|
|
@@ -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/client/csv-pick.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Step one of the import wizard: get the text.
|
|
3
3
|
*
|
|
4
|
-
* A file input and a drop zone, and both end in the same `onText` callback
|
|
4
|
+
* A file input and a drop zone, and both end in the same `onText` callback,
|
|
5
5
|
* which is the whole reason this is a module rather than two handlers. Nothing
|
|
6
6
|
* downstream knows or cares which way the file arrived.
|
|
7
7
|
*/
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*
|
|
8
8
|
* A coerced value that differs from the text carries the text as its `title`,
|
|
9
9
|
* so `"007"` landing in an integer column is visibly `7` and traceably `007`.
|
|
10
|
-
* A failure is ringed and shows the reason `coerceValue` gave
|
|
10
|
+
* A failure is ringed and shows the reason `coerceValue` gave: the same
|
|
11
11
|
* sentence the server would have returned.
|
|
12
12
|
*/
|
|
13
13
|
|
|
@@ -46,7 +46,7 @@ export function paintPreview(
|
|
|
46
46
|
* column it governs is.
|
|
47
47
|
*
|
|
48
48
|
* On by default for every kind except text, because `''` is a value in a text
|
|
49
|
-
* column and an error in every other one
|
|
49
|
+
* column and an error in every other one. See `defaultEmptyToNull`.
|
|
50
50
|
*/
|
|
51
51
|
function nullToggleRow(
|
|
52
52
|
model: ImportModel,
|
package/src/client/csv.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The CSV import wizard: pick → sniff → map → preview → commit.
|
|
3
3
|
*
|
|
4
|
-
* This file is the composition and nothing else. Each step is its own module
|
|
5
|
-
* `csv-pick.ts`, `csv-map.ts`, `csv-preview.ts`, `csv-commit.ts
|
|
4
|
+
* This file is the composition and nothing else. Each step is its own module:
|
|
5
|
+
* `csv-pick.ts`, `csv-map.ts`, `csv-preview.ts`, `csv-commit.ts`, and
|
|
6
6
|
* everything that *decides* anything is in `csv-model.ts` and is pure. What is
|
|
7
7
|
* left here is one mutable `model` reference and the repaint that follows it.
|
|
8
8
|
*
|
package/src/client/dom.ts
CHANGED
|
@@ -2,8 +2,7 @@
|
|
|
2
2
|
* The only place this client creates a node.
|
|
3
3
|
*
|
|
4
4
|
* **Nothing here writes `innerHTML`, and `client/safety.test.ts` asserts that
|
|
5
|
-
* no client module does.** Every value the grid renders is a database row
|
|
6
|
-
* whatever the last writer put there — and the operator's origin owns
|
|
5
|
+
* no client module does.** Every value the grid renders is a database row ( * whatever the last writer put there), and the operator's origin owns
|
|
7
6
|
* `/api/_db/rows`, so a string that reached the DOM as markup would be stored
|
|
8
7
|
* XSS to arbitrary row writes. `textContent` is not a style preference; it is
|
|
9
8
|
* the reason this plugin has no XSS surface. The dashboard's grid, which
|
|
@@ -16,7 +15,7 @@ export interface ElOptions {
|
|
|
16
15
|
text?: string
|
|
17
16
|
title?: string
|
|
18
17
|
id?: string
|
|
19
|
-
/** `aria-*`, `role`, `data
|
|
18
|
+
/** `aria-*`, `role`, `data-*`: anything set through `setAttribute`. */
|
|
20
19
|
attrs?: Record<string, string | number | boolean | null | undefined>
|
|
21
20
|
}
|
|
22
21
|
|
|
@@ -88,8 +87,7 @@ export function each<T>(
|
|
|
88
87
|
/**
|
|
89
88
|
* A `<table class="grid">` with its heading row already in it.
|
|
90
89
|
*
|
|
91
|
-
* The two lines this replaces
|
|
92
|
-
* had been written out three times, twice in `structure.ts` and once in
|
|
90
|
+
* The two lines this replaces (build the table, loop `<th>` into a `<tr>`) * had been written out three times, twice in `structure.ts` and once in
|
|
93
91
|
* `csv-preview.ts`. Rows are appended by the caller, because the three differ
|
|
94
92
|
* in what a row is: `structure.ts` has plain text cells, and `csv-preview.ts`
|
|
95
93
|
* puts a checkbox row directly under the headings.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The per-row dirty buffer, and the statement it becomes.
|
|
3
3
|
*
|
|
4
|
-
* **Pure
|
|
4
|
+
* **Pure: no DOM, no fetch.** This is where the editing model actually lives,
|
|
5
5
|
* so it is the part with tests. The grid is a view over it.
|
|
6
6
|
*
|
|
7
7
|
* Three properties it exists to hold:
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* latter, which means a reader can observe the row a third of the way
|
|
12
12
|
* through an edit the user considers atomic.
|
|
13
13
|
* 2. **The pre-image is kept.** `expect` is built from the row as it was
|
|
14
|
-
* *read*, never from a re-read at save time
|
|
14
|
+
* *read*, never from a re-read at save time: a re-read would defeat the
|
|
15
15
|
* concurrency check it exists to perform.
|
|
16
16
|
* 3. **Unchanged columns are dropped**, by `updatePlan`, so two people editing
|
|
17
17
|
* different columns of one row do not collide.
|
|
@@ -23,7 +23,7 @@ import { updatePlan } from '../shared/plan'
|
|
|
23
23
|
/**
|
|
24
24
|
* A row's identity, flattened to a string.
|
|
25
25
|
*
|
|
26
|
-
* `JSON.stringify` over the identity columns in their declared order
|
|
26
|
+
* `JSON.stringify` over the identity columns in their declared order: the
|
|
27
27
|
* order is the server's, from `identity.cols`, so it is stable across pages and
|
|
28
28
|
* across a composite key's spelling. `null` when the row does not carry every
|
|
29
29
|
* identity column, which is the same condition `updatePlan` reports as
|
|
@@ -50,7 +50,7 @@ export interface RowPlan {
|
|
|
50
50
|
/** The pre-image of every changed column that SQL can compare. */
|
|
51
51
|
expect: Record<string, unknown>
|
|
52
52
|
/**
|
|
53
|
-
* Changed columns SQL cannot compare
|
|
53
|
+
* Changed columns SQL cannot compare: `json` and `buffer`. The server
|
|
54
54
|
* refuses these in `expect` and demands `force: true` to write them without
|
|
55
55
|
* a concurrency check, so the UI has to say so rather than retry blindly.
|
|
56
56
|
*/
|
|
@@ -68,7 +68,7 @@ export type KindLookup = (column: string) => ColumnKind | undefined
|
|
|
68
68
|
*
|
|
69
69
|
* Bounded by construction: an entry exists only for a row the user has typed
|
|
70
70
|
* into, they are dropped on save and on revert, and changing table or page
|
|
71
|
-
* clears the whole session
|
|
71
|
+
* clears the whole session. There is no key here a page of data does not
|
|
72
72
|
* already hold (convention 6).
|
|
73
73
|
*/
|
|
74
74
|
export class EditSession {
|
|
@@ -144,7 +144,7 @@ export class EditSession {
|
|
|
144
144
|
* One row's edits as an UPDATE.
|
|
145
145
|
*
|
|
146
146
|
* `where` comes from the pre-image and `set` from the edits, which is what
|
|
147
|
-
* lets a primary key itself be edited
|
|
147
|
+
* lets a primary key itself be edited. See `updatePlan`'s own note.
|
|
148
148
|
*/
|
|
149
149
|
plan(
|
|
150
150
|
id: string,
|
|
@@ -189,7 +189,7 @@ export interface UndoEntry {
|
|
|
189
189
|
* Twenty rather than unlimited because each entry closes over a row's
|
|
190
190
|
* pre-image, and an unbounded stack of those is exactly the module-level
|
|
191
191
|
* unbounded cache convention 6 forbids. The pre-image is already being kept for
|
|
192
|
-
* the concurrency check, so undo costs nothing extra to record
|
|
192
|
+
* the concurrency check, so undo costs nothing extra to record: only to keep.
|
|
193
193
|
*/
|
|
194
194
|
export class UndoStack {
|
|
195
195
|
private readonly entries: UndoEntry[] = []
|
package/src/client/editors.ts
CHANGED
|
@@ -28,7 +28,7 @@ export interface EditorHooks {
|
|
|
28
28
|
export interface EditorHandle {
|
|
29
29
|
node: HTMLElement
|
|
30
30
|
focus: () => void
|
|
31
|
-
/** The current value, already NULL-aware. Not coerced
|
|
31
|
+
/** The current value, already NULL-aware. Not coerced: that is the caller's. */
|
|
32
32
|
read: () => unknown
|
|
33
33
|
}
|
|
34
34
|
|
|
@@ -100,7 +100,7 @@ function enumControl(column: SchemaColumn, value: unknown): Control {
|
|
|
100
100
|
* A checkbox, and it reads back a real boolean.
|
|
101
101
|
*
|
|
102
102
|
* A driver hands a boolean column back as `1`/`0` on SQLite and MySQL, so the
|
|
103
|
-
* *initial* state has to accept those spellings
|
|
103
|
+
* *initial* state has to accept those spellings, but what leaves here is
|
|
104
104
|
* `true`/`false`, which is what `coerceValue` wants and what makes `sameValue`
|
|
105
105
|
* agree that nothing changed when nothing did.
|
|
106
106
|
*/
|
|
@@ -123,7 +123,7 @@ function booleanControl(_column: SchemaColumn, value: unknown): Control {
|
|
|
123
123
|
*
|
|
124
124
|
* It refuses an ISO string with a `Z` and refuses one carrying milliseconds, so
|
|
125
125
|
* the value is trimmed to `YYYY-MM-DDTHH:mm` going in and handed back as a full
|
|
126
|
-
* ISO string going out
|
|
126
|
+
* ISO string going out: `coerceValue`'s `date` branch parses either, but the
|
|
127
127
|
* round trip has to be lossless in the direction the user sees.
|
|
128
128
|
*/
|
|
129
129
|
function dateControl(_column: SchemaColumn, value: unknown): Control {
|
|
@@ -199,8 +199,8 @@ export interface EditorOptions {
|
|
|
199
199
|
* An editor for one cell.
|
|
200
200
|
*
|
|
201
201
|
* The wrapper owns three things the control does not: the null toggle, the
|
|
202
|
-
* validity message, and key forwarding. Validation runs `coerceValue`
|
|
203
|
-
* same function the server runs
|
|
202
|
+
* validity message, and key forwarding. Validation runs `coerceValue` (the
|
|
203
|
+
* same function the server runs), so the message the user sees before sending
|
|
204
204
|
* is the message they would have got back.
|
|
205
205
|
*/
|
|
206
206
|
export function createEditor(
|
|
@@ -36,7 +36,7 @@ const OPTIONS = FILTER_OPS.map(op => ({ value: op, label: OP_LABELS[op] }))
|
|
|
36
36
|
* The bar.
|
|
37
37
|
*
|
|
38
38
|
* Every control writes a **whole new list** through `onChange` rather than
|
|
39
|
-
* mutating the one it was given
|
|
39
|
+
* mutating the one it was given: same discipline as the CSV wizard's model,
|
|
40
40
|
* and the reason a chip's state can never disagree with the URL.
|
|
41
41
|
*/
|
|
42
42
|
export function filterBar(ctx: FilterBuilderContext): HTMLElement {
|
|
@@ -54,7 +54,7 @@ export function filterBar(ctx: FilterBuilderContext): HTMLElement {
|
|
|
54
54
|
*
|
|
55
55
|
* The wire shape is a record keyed by column, so only the last filter on a
|
|
56
56
|
* column is sent. Rendering both chips and silently dropping one would be a
|
|
57
|
-
* screen that disagrees with the query
|
|
57
|
+
* screen that disagrees with the query: the one failure mode a filter builder
|
|
58
58
|
* must not have.
|
|
59
59
|
*/
|
|
60
60
|
function clashNote(columns: readonly string[]): HTMLElement {
|
|
@@ -130,9 +130,9 @@ function nextOnOp(entry: Filter, op: FilterOp): Filter {
|
|
|
130
130
|
}
|
|
131
131
|
|
|
132
132
|
/**
|
|
133
|
-
* The operand
|
|
133
|
+
* The operand: **absent** for `null` and `notnull`.
|
|
134
134
|
*
|
|
135
|
-
* Not disabled, not hidden by CSS: not built at all. A
|
|
135
|
+
* Not disabled, not hidden by CSS: not built at all. A grayed-out box still
|
|
136
136
|
* says "there is a value here", and there is not one.
|
|
137
137
|
*/
|
|
138
138
|
function valueInput(
|
|
@@ -169,7 +169,7 @@ function removeButton(
|
|
|
169
169
|
*
|
|
170
170
|
* Exported because they are the two operations the chip callbacks are made of,
|
|
171
171
|
* and an off-by-one in either would present as "removing one filter removed a
|
|
172
|
-
* different one"
|
|
172
|
+
* different one": a bug worth a test rather than a careful read.
|
|
173
173
|
*/
|
|
174
174
|
export function withAt(
|
|
175
175
|
filters: readonly Filter[],
|
|
@@ -186,7 +186,7 @@ export function withoutAt(filters: readonly Filter[], index: number): Filter[] {
|
|
|
186
186
|
/**
|
|
187
187
|
* The filter a foreign-key jump becomes.
|
|
188
188
|
*
|
|
189
|
-
* One `eq` per referenced column, which is exactly a row identity
|
|
189
|
+
* One `eq` per referenced column, which is exactly a row identity: this is the
|
|
190
190
|
* function that replaced `ViewState.focus`. Values stringify because the wire
|
|
191
191
|
* carries text and the ORM binds it as a parameter; the comparison happens in
|
|
192
192
|
* the database, against the column's own type.
|
package/src/client/fk.ts
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* per-row and therefore unbounded in principle; the bound is what makes that
|
|
16
16
|
* safe.
|
|
17
17
|
*
|
|
18
|
-
* The pure half
|
|
18
|
+
* The pure half (which column points where, and what key a row implies) is
|
|
19
19
|
* separated out so the graph reasoning can be read without the timers.
|
|
20
20
|
*/
|
|
21
21
|
|
|
@@ -25,8 +25,8 @@ import { type ForeignKeyInfo, type SchemaGraph, sameTable } from './meta'
|
|
|
25
25
|
/**
|
|
26
26
|
* A least-recently-used map, in nine lines.
|
|
27
27
|
*
|
|
28
|
-
* Core has `LRUCache` and this is not a preference for a private copy
|
|
29
|
-
* `bundleModule`
|
|
28
|
+
* Core has `LRUCache` and this is not a preference for a private copy:
|
|
29
|
+
* `bundleModule` externalizes every installed package, so importing it leaves
|
|
30
30
|
* `@bakery-framework/core/cache/lru` as a bare specifier in the emitted bundle
|
|
31
31
|
* and the browser cannot resolve it. Client code imports nothing from
|
|
32
32
|
* `@bakery-framework/*`; see the header of `shared/coerce.ts`.
|
|
@@ -101,7 +101,7 @@ export function fkForColumn(
|
|
|
101
101
|
* Every foreign-key column of one table, resolved once.
|
|
102
102
|
*
|
|
103
103
|
* `fkForColumn` is a linear scan of the whole graph, and the grid asked it per
|
|
104
|
-
* cell, per paint
|
|
104
|
+
* cell, per paint: a fifty-row page of twenty columns against forty foreign
|
|
105
105
|
* keys is forty thousand comparisons, each of which had `sameTable` allocate
|
|
106
106
|
* two lowercased strings, and `repaintRow`/`repaintCell` run it again. The scan
|
|
107
107
|
* does not depend on the row, so it happens once when the grid is built.
|
|
@@ -139,7 +139,7 @@ export function reverseFks(
|
|
|
139
139
|
/**
|
|
140
140
|
* The referenced row's identity, as this row names it.
|
|
141
141
|
*
|
|
142
|
-
* `null` when any participating column is NULL
|
|
142
|
+
* `null` when any participating column is NULL: an optional foreign key with
|
|
143
143
|
* no value points at nothing, and a predicate containing `NULL` matches no row
|
|
144
144
|
* anyway, so issuing the lookup would spend a request to learn that.
|
|
145
145
|
*/
|
|
@@ -156,7 +156,7 @@ export function fkKeyOf(
|
|
|
156
156
|
return key
|
|
157
157
|
}
|
|
158
158
|
|
|
159
|
-
/** `table|col|value
|
|
159
|
+
/** `table|col|value`: stable across pages, which is what makes caching pay. */
|
|
160
160
|
export function cacheKey(
|
|
161
161
|
refTable: string,
|
|
162
162
|
key: Record<string, unknown>,
|
|
@@ -171,8 +171,8 @@ export function cacheKey(
|
|
|
171
171
|
* What to show for a resolved row: the label column if the table has one, the
|
|
172
172
|
* identity otherwise.
|
|
173
173
|
*
|
|
174
|
-
* `graph.labels` is the server's pick
|
|
175
|
-
* of the identity
|
|
174
|
+
* `graph.labels` is the server's pick (the first text column that is not part
|
|
175
|
+
* of the identity), so a `users` row reads as `ada` rather than `41`.
|
|
176
176
|
*/
|
|
177
177
|
export function fkLabel(
|
|
178
178
|
graph: SchemaGraph | null,
|
|
@@ -197,7 +197,7 @@ export type Resolved = Record<string, unknown> | null
|
|
|
197
197
|
*/
|
|
198
198
|
export class FkResolver {
|
|
199
199
|
/**
|
|
200
|
-
* 500 entries
|
|
200
|
+
* 500 entries: ten pages of a fifty-row grid with a foreign key on every
|
|
201
201
|
* row. Past that the earliest are the ones the user has scrolled away from.
|
|
202
202
|
*/
|
|
203
203
|
private readonly cache = new BoundedCache<Resolved>(500)
|
package/src/client/grid-body.ts
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
* What goes *inside* a cell: the text, the classes, and the foreign-key button.
|
|
3
3
|
*
|
|
4
4
|
* Split out of `grid.ts` alongside the header and the row bar. This half is the
|
|
5
|
-
* one with no knowledge of the cursor or the editor
|
|
6
|
-
* returns a node
|
|
5
|
+
* one with no knowledge of the cursor or the editor (it is handed a value and
|
|
6
|
+
* returns a node), which is why it could leave.
|
|
7
7
|
*
|
|
8
8
|
* The `<button>` for a foreign key is a real button rather than a styled span,
|
|
9
9
|
* because it is activated by keyboard and read as an action by a screen reader.
|
|
@@ -33,7 +33,7 @@ export interface CellPaintContext {
|
|
|
33
33
|
* Write one cell's value into its `<td>`.
|
|
34
34
|
*
|
|
35
35
|
* The staged flag is passed in rather than read from a session here, so this
|
|
36
|
-
* module needs no `EditSession
|
|
36
|
+
* module needs no `EditSession`: a staged value and a stored value take
|
|
37
37
|
* exactly the same path and cannot diverge in how they look.
|
|
38
38
|
*/
|
|
39
39
|
export function paintCell(
|
|
@@ -69,7 +69,7 @@ function cellBody(
|
|
|
69
69
|
/**
|
|
70
70
|
* A foreign key as a link, resolving its label on hover.
|
|
71
71
|
*
|
|
72
|
-
* The resolver owns the delay, the cache and the cancellation
|
|
72
|
+
* The resolver owns the delay, the cache and the cancellation. See `fk.ts`.
|
|
73
73
|
* All that happens here is arming it on `pointerenter` and disarming it on
|
|
74
74
|
* `pointerleave`, which is what keeps a sweep across the grid from costing
|
|
75
75
|
* fifty round trips.
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* The grid's header row: the select-all box and the sortable column headings.
|
|
3
3
|
*
|
|
4
4
|
* Split out of `grid.ts` when that file passed six hundred lines. It is a clean
|
|
5
|
-
* seam because the header depends on nothing the grid mutates
|
|
5
|
+
* seam because the header depends on nothing the grid mutates: it is built
|
|
6
6
|
* once from the columns and the current sort, and never repainted. Cursor,
|
|
7
7
|
* editor and selection state all live on the other side of it.
|
|
8
8
|
*/
|
|
@@ -30,7 +30,7 @@ export function buildHead(ctx: HeaderContext): HTMLTableSectionElement {
|
|
|
30
30
|
|
|
31
31
|
/**
|
|
32
32
|
* The leading column exists on every table, editable or not: it carries the row
|
|
33
|
-
* panel's opener, which a read-only table needs just as much
|
|
33
|
+
* panel's opener, which a read-only table needs just as much. A forty-column
|
|
34
34
|
* row is unreadable in a grid whether or not it can be changed. The select-all
|
|
35
35
|
* checkbox joins it only when there is something to select *for*.
|
|
36
36
|
*/
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Under the row rather than in a toolbar, because "which row is this Save for"
|
|
5
5
|
* has exactly one unambiguous answer and it is proximity. It carries the
|
|
6
6
|
* changed-column list, the two buttons, and the place a failed save reports
|
|
7
|
-
* itself
|
|
7
|
+
* itself: a 409 belongs next to the row it is about, not in a toast.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { append, box, button, el } from './dom'
|
package/src/client/grid.ts
CHANGED
|
@@ -3,20 +3,20 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Two rules keep this file's functions under biome's complexity ceiling, and
|
|
5
5
|
* they are worth stating because the old `renderTable` broke both and scored
|
|
6
|
-
* 34 with a fraction of the
|
|
6
|
+
* 34 with a fraction of the behavior:
|
|
7
7
|
*
|
|
8
8
|
* 1. **No function both fetches and renders.** Everything here takes rows it
|
|
9
9
|
* was handed. `api.ts` does the asking.
|
|
10
10
|
* 2. **Every loop body is a named function.** `each()` from `dom.ts` takes the
|
|
11
11
|
* factory; an inline body would fold its branches into the caller's score.
|
|
12
12
|
*
|
|
13
|
-
* Three
|
|
13
|
+
* Three neighbors carry the parts that do not need the cursor:
|
|
14
14
|
* `grid-header.ts` builds the heading row, `grid-body.ts` paints a cell's
|
|
15
15
|
* contents, and `grid-rowbar.ts` owns the per-row save strip. The editing model
|
|
16
16
|
* itself is in `edit-session.ts` and the keyboard model in `cell.ts`, both pure
|
|
17
17
|
* and both tested. What is left here genuinely needs a DOM.
|
|
18
18
|
*
|
|
19
|
-
* `focus` used to live here too
|
|
19
|
+
* `focus` used to live here too: a row identity carried alongside the filters,
|
|
20
20
|
* because a substring `LIKE` could not name a row. With `eq` in the filter
|
|
21
21
|
* vocabulary a foreign-key jump is an ordinary filter, and the highlight, the
|
|
22
22
|
* `focusedMissing()` note and the whole concept are gone.
|
|
@@ -79,7 +79,7 @@ export class Grid {
|
|
|
79
79
|
this.paintCtx = {
|
|
80
80
|
table: ctx.table.name,
|
|
81
81
|
graph: ctx.graph,
|
|
82
|
-
// Once per grid, not once per cell per paint
|
|
82
|
+
// Once per grid, not once per cell per paint. See `fkMapFor`.
|
|
83
83
|
fks: fkMapFor(ctx.graph, ctx.table.name),
|
|
84
84
|
resolver: ctx.resolver,
|
|
85
85
|
onFollowFk: ctx.onFollowFk,
|
|
@@ -174,7 +174,7 @@ export class Grid {
|
|
|
174
174
|
/**
|
|
175
175
|
* Render one cell from the session's current view of it.
|
|
176
176
|
*
|
|
177
|
-
* Called on build, after every stage, and after an editor closes
|
|
177
|
+
* Called on build, after every stage, and after an editor closes, so a
|
|
178
178
|
* staged value and a saved value take exactly the same path.
|
|
179
179
|
*/
|
|
180
180
|
private repaintCell(
|
|
@@ -248,7 +248,7 @@ export class Grid {
|
|
|
248
248
|
// ---------------------------------------------------------------- editing
|
|
249
249
|
|
|
250
250
|
/**
|
|
251
|
-
* Open an editor
|
|
251
|
+
* Open an editor, or explain why not.
|
|
252
252
|
*
|
|
253
253
|
* A double-click on a read-only table says what is wrong *here*, at the
|
|
254
254
|
* moment the user asked. The alternative is a grid that looks editable and
|
|
@@ -293,8 +293,7 @@ export class Grid {
|
|
|
293
293
|
// write, and a multi-column edit must stay one statement.
|
|
294
294
|
//
|
|
295
295
|
// `relatedTarget` is checked because `focusout` also fires for movement
|
|
296
|
-
// *inside* the editor
|
|
297
|
-
// and committing there would close the editor the user is still in.
|
|
296
|
+
// *inside* the editor (tabbing from the input to the null toggle is one), // and committing there would close the editor the user is still in.
|
|
298
297
|
handle.node.addEventListener('focusout', event => {
|
|
299
298
|
const next = (event as FocusEvent).relatedTarget
|
|
300
299
|
if (next instanceof Node && handle.node.contains(next)) return
|
|
@@ -425,7 +424,7 @@ export class Grid {
|
|
|
425
424
|
return this.slots[index]?.row
|
|
426
425
|
}
|
|
427
426
|
|
|
428
|
-
/** The row under the cursor
|
|
427
|
+
/** The row under the cursor: what a keyboard-only user means by "this row". */
|
|
429
428
|
focusedRow(): Record<string, unknown> | undefined {
|
|
430
429
|
return this.slots[this.cursor.row]?.row
|
|
431
430
|
}
|
package/src/client/meta.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* What the server said about a column, and the four decisions the grid makes
|
|
3
3
|
* from it.
|
|
4
4
|
*
|
|
5
|
-
* **Pure
|
|
5
|
+
* **Pure, no DOM.** Every branch the editor would otherwise take on a column's
|
|
6
6
|
* type is resolved here, once, into a `WidgetKind`, and every consumer
|
|
7
7
|
* dispatches through a record of small functions rather than an if-chain. A
|
|
8
8
|
* record of five functions has no cognitive complexity; five `else if`s have
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
*
|
|
12
12
|
* The shapes below mirror `endpoints/read.ts` exactly. They are re-declared
|
|
13
13
|
* rather than imported because that module reaches for the ORM and for
|
|
14
|
-
* `node:async_hooks` through its
|
|
14
|
+
* `node:async_hooks` through its neighbors, and this one is compiled into the
|
|
15
15
|
* browser bundle.
|
|
16
16
|
*/
|
|
17
17
|
|
|
@@ -48,7 +48,7 @@ export interface SchemaTable {
|
|
|
48
48
|
rowCount: number
|
|
49
49
|
columns: SchemaColumn[]
|
|
50
50
|
identity: Identity
|
|
51
|
-
/** Optional so a fixture
|
|
51
|
+
/** Optional so a fixture (and an older server) need not carry them. */
|
|
52
52
|
indexes?: SchemaIndex[]
|
|
53
53
|
isView?: boolean
|
|
54
54
|
writable: boolean
|
|
@@ -87,7 +87,7 @@ export type WidgetKind = 'enum' | 'boolean' | 'date' | 'json' | 'text'
|
|
|
87
87
|
|
|
88
88
|
/**
|
|
89
89
|
* `enum` first, because it is a constraint on a `string` column rather than a
|
|
90
|
-
* kind of its own
|
|
90
|
+
* kind of its own: the server reports `kind: 'string'` with an `enum` list,
|
|
91
91
|
* and a free-text box over a checked list is a guaranteed round trip to a
|
|
92
92
|
* `not_in_enum` error.
|
|
93
93
|
*/
|
|
@@ -124,7 +124,7 @@ export function columnMeta(column: SchemaColumn): ColumnMeta {
|
|
|
124
124
|
* `NULL` is rendered as the word and styled as absent, which is the
|
|
125
125
|
* distinction the whole editor turns on: an empty string is a value and a
|
|
126
126
|
* blank cell would make the two look identical. Binary is summarised rather
|
|
127
|
-
* than dumped
|
|
127
|
+
* than dumped: a blob rendered as its bytes is thousands of characters of
|
|
128
128
|
* noise that also makes the row unselectable.
|
|
129
129
|
*/
|
|
130
130
|
export function cellText(value: unknown): string {
|
|
@@ -139,7 +139,7 @@ function safeJson(value: unknown): string {
|
|
|
139
139
|
return JSON.stringify(value) ?? String(value)
|
|
140
140
|
} catch {
|
|
141
141
|
// A cycle or a BigInt. The cell still has to render something, and the
|
|
142
|
-
// failure is cosmetic
|
|
142
|
+
// failure is cosmetic: the value itself is untouched.
|
|
143
143
|
return String(value)
|
|
144
144
|
}
|
|
145
145
|
}
|
package/src/client/page.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* what stopped the entry module growing back into the 197-line
|
|
7
7
|
* fetch-and-render `renderTable` it replaced.
|
|
8
8
|
*
|
|
9
|
-
* The shape is now three regions rather than two
|
|
9
|
+
* The shape is now three regions rather than two: sidebar, then a column
|
|
10
10
|
* holding the tab strip, the active view and the status bar. The sidebar and
|
|
11
11
|
* the status bar outlive a tab switch; only `#main` is replaced. The painters
|
|
12
12
|
* take callbacks rather than reaching for the entry's state, so the page has no
|
|
@@ -78,7 +78,7 @@ export class Page {
|
|
|
78
78
|
* The Data view: filters, the bulk toolbar, the grid, the pager.
|
|
79
79
|
*
|
|
80
80
|
* `data` is the page the server just returned and `ms` is what it said it
|
|
81
|
-
* cost
|
|
81
|
+
* cost: the status bar's timing is the envelope's own number rather than a
|
|
82
82
|
* round trip timed here, so a slow filter is attributable.
|
|
83
83
|
*/
|
|
84
84
|
paint(
|
|
@@ -232,14 +232,14 @@ export class Page {
|
|
|
232
232
|
private readOnlyBanner(table: SchemaTable): HTMLElement {
|
|
233
233
|
return el('p', {
|
|
234
234
|
class: 'banner warn',
|
|
235
|
-
text: `read-only
|
|
235
|
+
text: `read-only: ${readOnlyReason(this.state, table)}`,
|
|
236
236
|
})
|
|
237
237
|
}
|
|
238
238
|
|
|
239
239
|
/**
|
|
240
240
|
* The filter builder.
|
|
241
241
|
*
|
|
242
|
-
* A chip per condition
|
|
242
|
+
* A chip per condition (column, operator, value, remove) where there used
|
|
243
243
|
* to be one text box per column that could only ever mean "contains". Any
|
|
244
244
|
* change resets to page 1, because a filtered result has different pages and
|
|
245
245
|
* staying on page 7 of a set that now has two is a blank screen.
|