@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/panel.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The row side panel
|
|
2
|
+
* The row side panel: one row, as a form.
|
|
3
3
|
*
|
|
4
4
|
* Promoted from `drawer.ts`, which did the same job with less of it. The grid
|
|
5
5
|
* is the right shape for scanning and the wrong shape for a forty-column row:
|
|
@@ -14,11 +14,11 @@
|
|
|
14
14
|
* beats a cell for those two.
|
|
15
15
|
* - **Both directions of the graph.** "References" jumps to the row this one
|
|
16
16
|
* points at; "Referenced by" lists the rows pointing here.
|
|
17
|
-
* - **Exact counts.** They used to be
|
|
17
|
+
* - **Exact counts.** They used to be labeled approximate and were: the only
|
|
18
18
|
* filter available was a substring `LIKE`, so a row with id `1` counted `11`
|
|
19
19
|
* and `21` too. With `eq` the number is the number.
|
|
20
20
|
*
|
|
21
|
-
* "Referenced by" is still **lazy
|
|
21
|
+
* "Referenced by" is still **lazy**: one request per referencing table, issued
|
|
22
22
|
* when the section is opened. Doing that for every visible row would be the
|
|
23
23
|
* per-cell `fetch` that `fk.ts` exists to avoid, in a different costume.
|
|
24
24
|
*/
|
|
@@ -103,7 +103,7 @@ function fields(ctx: PanelContext, id: string | null): HTMLElement {
|
|
|
103
103
|
/**
|
|
104
104
|
* One column: its name, its declared type, and an editor or a read-only value.
|
|
105
105
|
*
|
|
106
|
-
* The same `createEditor` the grid opens in a cell
|
|
106
|
+
* The same `createEditor` the grid opens in a cell, including the null toggle,
|
|
107
107
|
* which is the whole reason a 40-column row is editable here at all: several of
|
|
108
108
|
* those columns are nullable, and a form of plain text inputs cannot say so.
|
|
109
109
|
* `multiline` is the one difference, and it is what makes this the place long
|
|
@@ -136,7 +136,7 @@ function field(
|
|
|
136
136
|
current,
|
|
137
137
|
{
|
|
138
138
|
// In the panel there is no cursor to move, so every change stages
|
|
139
|
-
// immediately. Same buffer, same Save
|
|
139
|
+
// immediately. Same buffer, same Save: the difference is only that the
|
|
140
140
|
// grid needs a commit key to know where to go next and this does not.
|
|
141
141
|
onInput: value => {
|
|
142
142
|
ctx.session.stage(id, ctx.row, column.name, value)
|
|
@@ -178,7 +178,7 @@ function footer(
|
|
|
178
178
|
ctx.session.drop(id)
|
|
179
179
|
ctx.onDirtyChange()
|
|
180
180
|
message.textContent =
|
|
181
|
-
'reverted
|
|
181
|
+
'reverted: reopen the row to see the stored values'
|
|
182
182
|
},
|
|
183
183
|
{ class: 'btn' },
|
|
184
184
|
),
|
|
@@ -187,7 +187,7 @@ function footer(
|
|
|
187
187
|
}
|
|
188
188
|
|
|
189
189
|
/**
|
|
190
|
-
* The rows *this* row points at
|
|
190
|
+
* The rows *this* row points at: one link per outgoing foreign key.
|
|
191
191
|
*
|
|
192
192
|
* Not lazy, because it costs nothing: the key is already in the row, so the
|
|
193
193
|
* link is built from data in hand and only the click spends a request. A key
|
|
@@ -235,7 +235,7 @@ function referenceRow(ctx: PanelContext, relation: Relation): HTMLElement {
|
|
|
235
235
|
/**
|
|
236
236
|
* Tables that point at this row, resolved when the section is opened.
|
|
237
237
|
*
|
|
238
|
-
* The counts are **exact**. They used to be
|
|
238
|
+
* The counts are **exact**. They used to be labeled approximate and were:
|
|
239
239
|
* the only filter the table-data endpoint offered was `col LIKE '%value%'`, so
|
|
240
240
|
* a row with id `1` matched `11` and `21` too. `eq` removed the caveat along
|
|
241
241
|
* with the reason for it.
|
package/src/client/relations.ts
CHANGED
|
@@ -20,7 +20,7 @@ import type { ForeignKeyInfo, SchemaGraph } from './meta'
|
|
|
20
20
|
import { sameTable } from './meta'
|
|
21
21
|
|
|
22
22
|
export interface Relation {
|
|
23
|
-
/** The columns on the near side
|
|
23
|
+
/** The columns on the near side: this table's, for `outgoing`. */
|
|
24
24
|
cols: string[]
|
|
25
25
|
/** The table at the other end. */
|
|
26
26
|
table: string
|
|
@@ -43,10 +43,10 @@ export interface Relations {
|
|
|
43
43
|
* programming: `getForeignKeys()` reports whatever the database calls a table
|
|
44
44
|
* while `getConstraints()` is camel-keyed, and the graph endpoint passes both
|
|
45
45
|
* through untouched. Comparing literally makes every foreign key on a
|
|
46
|
-
* `snake_case` schema invisible
|
|
46
|
+
* `snake_case` schema invisible: the whole feature silently absent, which is
|
|
47
47
|
* exactly what `meta.ts`'s own note on `sameTable` records.
|
|
48
48
|
*
|
|
49
|
-
* A self-reference
|
|
49
|
+
* A self-reference (a `manager_id` pointing at the same table's `id`) appears
|
|
50
50
|
* in **both** lists, which is correct: it is genuinely a key this table
|
|
51
51
|
* declares and genuinely a key that points here.
|
|
52
52
|
*/
|
|
@@ -189,7 +189,7 @@ function incomingRow(ctx: RelationsContext, relation: Relation): HTMLElement {
|
|
|
189
189
|
* The filters that open the rows of `relation.table` pointing at one row.
|
|
190
190
|
*
|
|
191
191
|
* Exported for the row panel, which is where "referenced by *this row*" lives.
|
|
192
|
-
* `eq` per column, so the count and the page are exact
|
|
192
|
+
* `eq` per column, so the count and the page are exact: this used to be a
|
|
193
193
|
* substring `LIKE` and the panel had to label its counts "approximate".
|
|
194
194
|
*/
|
|
195
195
|
export function filtersForIncoming(
|
package/src/client/save.ts
CHANGED
|
@@ -43,7 +43,7 @@ function kindLookup(
|
|
|
43
43
|
*
|
|
44
44
|
* The pre-image goes as `expect`, so the statement is `identity ∧ expect` and a
|
|
45
45
|
* concurrent edit is a 409 rather than a silent overwrite. `force` is sent only
|
|
46
|
-
* when a changed column is `json` or `buffer
|
|
46
|
+
* when a changed column is `json` or `buffer`: kinds SQL cannot compare, so
|
|
47
47
|
* there is no predicate to guard them with.
|
|
48
48
|
*/
|
|
49
49
|
export async function saveRow(
|
|
@@ -113,8 +113,8 @@ async function reportFailure(
|
|
|
113
113
|
/**
|
|
114
114
|
* Keep mine / Take theirs, with the server's copy of the row in hand.
|
|
115
115
|
*
|
|
116
|
-
* The 409 body carries the row as it now stands
|
|
117
|
-
* attaches it
|
|
116
|
+
* The 409 body carries the row as it now stands (that is why the endpoint
|
|
117
|
+
* attaches it), so this is a real choice rather than a prompt to guess.
|
|
118
118
|
*/
|
|
119
119
|
function offerResolution(
|
|
120
120
|
table: SchemaTable,
|
|
@@ -172,7 +172,7 @@ function offerResolution(
|
|
|
172
172
|
/**
|
|
173
173
|
* Keep mine: the same `set`, with `expect` taken from *their* row.
|
|
174
174
|
*
|
|
175
|
-
* Not `expect: {}
|
|
175
|
+
* Not `expect: {}`. That is last-write-wins, which the endpoint refuses to
|
|
176
176
|
* default to for exactly this reason. Rebasing on the row that was just shown
|
|
177
177
|
* means a *third* edit arriving between the 409 and this retry is a 409 again,
|
|
178
178
|
* which is the correct answer rather than an inconvenience.
|
package/src/client/sidebar.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* everything else and had no business sharing a module with the grid's chrome.
|
|
7
7
|
*
|
|
8
8
|
* A **single click opens a preview tab** and a **double click makes it
|
|
9
|
-
* permanent
|
|
9
|
+
* permanent**: the two are wired here and defined in `tabs.ts`.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import { append, box, button, each, el, on } from './dom'
|
|
@@ -46,7 +46,7 @@ export function renderSidebar(ctx: SidebarContext): HTMLElement {
|
|
|
46
46
|
* The system-tables checkbox, present only when there is something to reveal.
|
|
47
47
|
*
|
|
48
48
|
* `__bakery_schema` is the ORM's sync ledger and is not the user's data, so it
|
|
49
|
-
* is hidden by default
|
|
49
|
+
* is hidden by default, but every real client offers the toggle rather than
|
|
50
50
|
* hiding such tables outright, because a ledger row is occasionally exactly
|
|
51
51
|
* what someone needs to see. The count is in the label so the checkbox says
|
|
52
52
|
* what it would do before it is clicked.
|
|
@@ -65,7 +65,7 @@ function systemToggle(ctx: SidebarContext): HTMLElement | null {
|
|
|
65
65
|
const label = el('label', {
|
|
66
66
|
class: 'note system-toggle',
|
|
67
67
|
text: ` show system tables (${hidden})`,
|
|
68
|
-
title: "the framework's own bookkeeping
|
|
68
|
+
title: "the framework's own bookkeeping: the ORM sync ledger",
|
|
69
69
|
})
|
|
70
70
|
label.prepend(check)
|
|
71
71
|
return label
|
|
@@ -76,8 +76,8 @@ function tableButton(ctx: SidebarContext, table: SchemaTable): HTMLElement {
|
|
|
76
76
|
class: 'table-btn',
|
|
77
77
|
})
|
|
78
78
|
// `dblclick` fires *after* its two `click`s, so the single click has already
|
|
79
|
-
// opened the preview tab and this only promotes it
|
|
80
|
-
// Code's
|
|
79
|
+
// opened the preview tab and this only promotes it, which is exactly VS
|
|
80
|
+
// Code's behavior and needs no suppression of the first click.
|
|
81
81
|
on(node, 'dblclick', () => ctx.onOpen(table.name))
|
|
82
82
|
|
|
83
83
|
if (table.name === ctx.activeTable) node.classList.add('active')
|
|
@@ -96,7 +96,7 @@ function badgeFor(table: SchemaTable): HTMLElement | null {
|
|
|
96
96
|
return el('span', {
|
|
97
97
|
class: 'ro',
|
|
98
98
|
text: '⊞',
|
|
99
|
-
title: 'a view
|
|
99
|
+
title: 'a view: it has no rows of its own to address',
|
|
100
100
|
})
|
|
101
101
|
}
|
|
102
102
|
if (!table.writable) {
|
package/src/client/state.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* The view lives in the **hash** rather than the query string on purpose. The
|
|
10
10
|
* explorer is reached with `?db-key=…` on a first visit, the client scrubs that
|
|
11
11
|
* parameter out of the URL immediately, and a view state sharing the query
|
|
12
|
-
* string would be scrubbed along with it
|
|
12
|
+
* string would be scrubbed along with it, or worse, would have to be
|
|
13
13
|
* reconstructed by an edit to the same `URLSearchParams` the credential was
|
|
14
14
|
* just removed from. A hash also never reaches the server, which is the right
|
|
15
15
|
* place for "which row am I on".
|
|
@@ -34,7 +34,7 @@ export interface ViewState {
|
|
|
34
34
|
sortBy: string | null
|
|
35
35
|
sortOrder: 'ASC' | 'DESC'
|
|
36
36
|
/**
|
|
37
|
-
* Column, operator and operand
|
|
37
|
+
* Column, operator and operand: several, each removable.
|
|
38
38
|
*
|
|
39
39
|
* A **list** rather than a record, because the builder lets a filter exist
|
|
40
40
|
* before it has a column or a value and a record cannot hold a half-built
|
|
@@ -62,7 +62,7 @@ export function defaultView(table = ''): ViewState {
|
|
|
62
62
|
* omitted rather than written, so the common case is `t=parcels`.
|
|
63
63
|
*
|
|
64
64
|
* There is no `r` any more. It carried a row identity because a filter could
|
|
65
|
-
* not name a row
|
|
65
|
+
* not name a row (`id=1` matched `11` under a substring `LIKE`), and with `eq`
|
|
66
66
|
* available a foreign-key jump is just a filter. An old link with `r=` decodes
|
|
67
67
|
* to the same page it always did, minus the highlight.
|
|
68
68
|
*/
|
|
@@ -159,7 +159,7 @@ export function tableOf(
|
|
|
159
159
|
*
|
|
160
160
|
* Matched against the **raw** database name, which is what the schema report
|
|
161
161
|
* carries: `introspect()` builds from `getSchema()`, and that speaks raw names.
|
|
162
|
-
* (`getConstraints()` camel-cases, so the same table is `bakerySchema` there
|
|
162
|
+
* (`getConstraints()` camel-cases, so the same table is `bakerySchema` there:
|
|
163
163
|
* that spelling never reaches this client, and matching it here would risk
|
|
164
164
|
* hiding a user table that happens to be called `bakerySchema`.)
|
|
165
165
|
*/
|
package/src/client/statusbar.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* Every database client has one, and it earns its space by answering three
|
|
6
6
|
* questions that otherwise need a round trip through the developer tools: how
|
|
7
7
|
* many rows there really are, how long the server took, and whether this
|
|
8
|
-
* session can write at all. The last one is the reason it exists here
|
|
8
|
+
* session can write at all. The last one is the reason it exists here: the
|
|
9
9
|
* access level used to be a line of sidebar text that scrolled away.
|
|
10
10
|
*
|
|
11
11
|
* `statusParts` is **pure** and is where the wording lives, so the segments are
|
|
@@ -30,7 +30,7 @@ export interface StatusFacts {
|
|
|
30
30
|
/**
|
|
31
31
|
* The segments, in order, already worded.
|
|
32
32
|
*
|
|
33
|
-
* Absent facts are **omitted rather than rendered as a placeholder
|
|
33
|
+
* Absent facts are **omitted rather than rendered as a placeholder**: a
|
|
34
34
|
* `page 1 / ?` teaches nobody anything, and `getData` genuinely does not always
|
|
35
35
|
* return a total. An empty array is the honest answer for "nothing is open".
|
|
36
36
|
*/
|
|
@@ -70,7 +70,7 @@ function formatCount(count: number): string {
|
|
|
70
70
|
* Sub-millisecond timings keep one decimal.
|
|
71
71
|
*
|
|
72
72
|
* `getElapsed` returns a float, and a fast query rounding to `0 ms` reads as
|
|
73
|
-
* "not measured" rather than "fast"
|
|
73
|
+
* "not measured" rather than "fast", which is the opposite of what it means.
|
|
74
74
|
*/
|
|
75
75
|
function formatMs(ms: number): string {
|
|
76
76
|
return ms < 10 ? ms.toFixed(1) : String(Math.round(ms))
|
|
@@ -89,7 +89,7 @@ export class StatusBar {
|
|
|
89
89
|
* The last facts painted.
|
|
90
90
|
*
|
|
91
91
|
* Kept so `bumpDirty` can repaint one segment without the caller having to
|
|
92
|
-
* reconstruct the row count, the page and the timing
|
|
92
|
+
* reconstruct the row count, the page and the timing, which it cannot,
|
|
93
93
|
* since a staged edit does not re-fetch and the alternative would be showing
|
|
94
94
|
* a stale count or none.
|
|
95
95
|
*/
|
|
@@ -122,8 +122,8 @@ export class StatusBar {
|
|
|
122
122
|
|
|
123
123
|
private render(): void {
|
|
124
124
|
this.line.textContent = statusParts(this.facts).join(' · ')
|
|
125
|
-
// The dirty count is the one segment worth
|
|
126
|
-
// last
|
|
125
|
+
// The dirty count is the one segment worth coloring, and it is always
|
|
126
|
+
// last, so the class goes on the bar rather than on a span nobody can see
|
|
127
127
|
// when the bar is empty.
|
|
128
128
|
this.node.classList.toggle('dirty', this.facts.dirtyRows > 0)
|
|
129
129
|
}
|
package/src/client/structure.ts
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The Structure view: what the table *is*, as opposed to what is in it.
|
|
3
3
|
*
|
|
4
|
-
* Every serious client
|
|
4
|
+
* Every serious client (DBeaver, DataGrip, Devart) splits a table into Data
|
|
5
5
|
* and its structure, and keeps them apart. This one was the single biggest
|
|
6
6
|
* omission: `GET /api/_db/schema` has always returned the type, nullability,
|
|
7
7
|
* default, enum values, auto-increment flag and resolved row identity of every
|
|
8
8
|
* column, and the client used all of it *only* to pick which editor widget to
|
|
9
9
|
* open. None of it was ever on screen. The reason a table is read-only was the
|
|
10
|
-
* worst of it
|
|
10
|
+
* worst of it: it existed, in the server's own words, and appeared nowhere
|
|
11
11
|
* except a tooltip on a padlock.
|
|
12
12
|
*
|
|
13
13
|
* `structureRows` is **pure** and is the whole derivation, so what each column
|
|
@@ -22,7 +22,7 @@ export interface StructureRow {
|
|
|
22
22
|
name: string
|
|
23
23
|
type: string
|
|
24
24
|
nullable: string
|
|
25
|
-
/** `AUTO`, a literal `DEFAULT`, or empty
|
|
25
|
+
/** `AUTO`, a literal `DEFAULT`, or empty, never a guess. */
|
|
26
26
|
default: string
|
|
27
27
|
/** `PK`, `unique`, or empty. */
|
|
28
28
|
key: string
|
|
@@ -40,7 +40,7 @@ export interface StructureRow {
|
|
|
40
40
|
*
|
|
41
41
|
* - **`hasDefault` and `autoIncrement` are different facts**, and an
|
|
42
42
|
* auto-increment column is reported as such rather than as "has a default".
|
|
43
|
-
* They differ in the one place it matters
|
|
43
|
+
* They differ in the one place it matters (`omittableOnInsert`), and
|
|
44
44
|
* conflating them on screen would teach the reader the wrong model.
|
|
45
45
|
* - **`notnull` is not the negation of `nullable` here.** The server sends
|
|
46
46
|
* both, from two different introspection calls (`getConstraints()` when it
|
|
@@ -114,8 +114,8 @@ const INDEX_HEADINGS = ['index', 'type', 'columns'] as const
|
|
|
114
114
|
/**
|
|
115
115
|
* A scrolling table of plain text cells.
|
|
116
116
|
*
|
|
117
|
-
* Columns and indexes are the same table twice
|
|
118
|
-
* `<td class="cell">` per row
|
|
117
|
+
* Columns and indexes are the same table twice (headings, then a `<tr>` of
|
|
118
|
+
* `<td class="cell">` per row), so they are one builder and two `string[][]`.
|
|
119
119
|
*/
|
|
120
120
|
function dataTable(
|
|
121
121
|
headings: readonly string[],
|
|
@@ -150,7 +150,7 @@ export function renderStructure(ctx: StructureContext): HTMLElement {
|
|
|
150
150
|
}
|
|
151
151
|
|
|
152
152
|
/**
|
|
153
|
-
* How a row of this table is named
|
|
153
|
+
* How a row of this table is named, and, when it cannot be, why.
|
|
154
154
|
*
|
|
155
155
|
* First rather than last, and a banner rather than a footnote. "This table is
|
|
156
156
|
* read-only" with no reason is the message that generates support questions;
|
|
@@ -165,7 +165,7 @@ function identitySection(ctx: StructureContext): HTMLElement {
|
|
|
165
165
|
section.appendChild(
|
|
166
166
|
el('p', {
|
|
167
167
|
class: 'banner warn',
|
|
168
|
-
text: `no row of this table can be named
|
|
168
|
+
text: `no row of this table can be named: ${ctx.reason}`,
|
|
169
169
|
}),
|
|
170
170
|
)
|
|
171
171
|
return section
|
package/src/client/tabs.ts
CHANGED
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Open tables as tabs, with VS Code's preview semantics.
|
|
3
3
|
*
|
|
4
|
-
* **Pure
|
|
4
|
+
* **Pure, no DOM.** Every transition is a function from a state to a state, so
|
|
5
5
|
* the rules below are asserted in `tabs.test.ts` rather than inferred from a
|
|
6
6
|
* click handler. `tabstrip.ts` is the view over this.
|
|
7
7
|
*
|
|
8
|
-
* The
|
|
8
|
+
* The behavior is Supabase Studio's, which is VS Code's:
|
|
9
9
|
*
|
|
10
|
-
* - A **single click** opens a *preview* tab
|
|
10
|
+
* - A **single click** opens a *preview* tab: rendered italic, and **replaced
|
|
11
11
|
* in place** by the next single click. Browsing twelve tables therefore
|
|
12
12
|
* leaves one tab open, not twelve, which is the whole point.
|
|
13
13
|
* - **Double-clicking**, or **editing**, makes it permanent. Investment is what
|
|
14
14
|
* promotes a tab; merely looking at it is not.
|
|
15
15
|
* - A table that is already open is *selected* rather than reopened, and its
|
|
16
|
-
* own view state
|
|
16
|
+
* own view state (page, sort, filters) is left exactly as it was. That is
|
|
17
17
|
* the property that makes tabs worth having: switching back restores.
|
|
18
18
|
*
|
|
19
19
|
* There is at most one preview tab, which is what makes "replace the preview"
|
|
@@ -58,8 +58,8 @@ function previewIndex(state: TabsState): number {
|
|
|
58
58
|
/**
|
|
59
59
|
* Single click: open as preview.
|
|
60
60
|
*
|
|
61
|
-
* Three cases in order, and the order is the
|
|
62
|
-
* wins over everything
|
|
61
|
+
* Three cases in order, and the order is the behavior. An already-open table
|
|
62
|
+
* wins over everything: reopening it would throw away the page and filters the
|
|
63
63
|
* user left it on, which is precisely what tabs exist to preserve.
|
|
64
64
|
*/
|
|
65
65
|
export function openPreview(state: TabsState, view: ViewState): TabsState {
|
|
@@ -109,8 +109,8 @@ export function selectTab(state: TabsState, index: number): TabsState {
|
|
|
109
109
|
/**
|
|
110
110
|
* Close one tab.
|
|
111
111
|
*
|
|
112
|
-
* Closing the active tab activates whatever slid into its slot
|
|
113
|
-
* right
|
|
112
|
+
* Closing the active tab activates whatever slid into its slot (the tab to the
|
|
113
|
+
* right), and the last tab when there is nothing to the right. Closing anything
|
|
114
114
|
* left of the active one shifts the active index down so the *same* tab stays
|
|
115
115
|
* selected, which is the bug the naive version has.
|
|
116
116
|
*/
|
|
@@ -172,7 +172,7 @@ export function pruneTabs(
|
|
|
172
172
|
/**
|
|
173
173
|
* The whole strip in a hash.
|
|
174
174
|
*
|
|
175
|
-
* `x` repeats, one per tab, each holding a whole encoded view
|
|
175
|
+
* `x` repeats, one per tab, each holding a whole encoded view: `URLSearchParams`
|
|
176
176
|
* escapes the inner `&` on the way in and unescapes it on the way out, so the
|
|
177
177
|
* nesting needs no encoding of its own. `a` is the active index and `w` the
|
|
178
178
|
* preview one; both are indexes rather than flags because there is at most one
|
|
@@ -191,7 +191,7 @@ export function decodeTabs(hash: string): TabsState {
|
|
|
191
191
|
const params = new URLSearchParams(hash.replace(/^#/, ''))
|
|
192
192
|
const encoded = params.getAll('x')
|
|
193
193
|
|
|
194
|
-
// A pre-tabs link
|
|
194
|
+
// A pre-tabs link (`#t=parcels&p=2`) is one permanent tab. Those links are
|
|
195
195
|
// in chat logs and bookmarks, and silently rendering nothing for them would
|
|
196
196
|
// be the most visible possible regression.
|
|
197
197
|
if (!encoded.length) {
|
package/src/client/tabstrip.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The tab strip, and the view switcher under it.
|
|
3
3
|
*
|
|
4
|
-
* Two rows, and **exactly two
|
|
4
|
+
* Two rows, and **exactly two**: the table tabs, then Data / Structure /
|
|
5
5
|
* Relations for whichever table is active. Beekeeper sells itself on not having
|
|
6
6
|
* "tabs within tabs", and it is right: one level of nesting is navigable and
|
|
7
7
|
* two is a maze. Nothing below this line gets its own strip.
|
|
@@ -19,7 +19,7 @@ export interface TabStripContext {
|
|
|
19
19
|
/** Double-click on a preview tab: keep it. */
|
|
20
20
|
onPromote: (index: number) => void
|
|
21
21
|
onClose: (index: number) => void
|
|
22
|
-
/** The `+` button
|
|
22
|
+
/** The `+` button: back to the table picker. */
|
|
23
23
|
onNew: () => void
|
|
24
24
|
}
|
|
25
25
|
|
|
@@ -50,7 +50,7 @@ function tabNode(ctx: TabStripContext, index: number): HTMLElement {
|
|
|
50
50
|
|
|
51
51
|
const label = button(tab.view.table, () => ctx.onSelect(index), {
|
|
52
52
|
class: 'tab-label',
|
|
53
|
-
title: tab.preview ? `${tab.view.table}
|
|
53
|
+
title: tab.preview ? `${tab.view.table}: preview` : tab.view.table,
|
|
54
54
|
})
|
|
55
55
|
on(label, 'dblclick', () => ctx.onPromote(index))
|
|
56
56
|
node.appendChild(label)
|
package/src/client.ts
CHANGED
|
@@ -1,23 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The explorer's browser entry: boot, then wiring. Nothing else.
|
|
3
3
|
*
|
|
4
|
-
* This file used to be the whole client
|
|
4
|
+
* This file used to be the whole client: 197 lines, one `renderTable` that
|
|
5
5
|
* fetched, built a header, built a body and built a pager, and scored **34**
|
|
6
6
|
* against biome's ceiling of 25. Growing that into a data editor would have
|
|
7
7
|
* meant growing that one function, so the shape changed first. The pieces live
|
|
8
|
-
* under `client/`, each obeying two mechanical rules
|
|
9
|
-
* fetches and renders**, and **every loop body is a named function**
|
|
8
|
+
* under `client/`, each obeying two mechanical rules (**no function both
|
|
9
|
+
* fetches and renders**, and **every loop body is a named function**), and what
|
|
10
10
|
* is left here is the composition: state, routing, and which callback goes
|
|
11
11
|
* where.
|
|
12
12
|
*
|
|
13
13
|
* The layout is the one a database client is expected to have: a table list, a
|
|
14
14
|
* strip of table tabs with VS Code preview semantics, Data / Structure /
|
|
15
|
-
* Relations under it
|
|
15
|
+
* Relations under it (**one level of nesting and no more**), and a status bar.
|
|
16
16
|
* Tab state lives in `client/tabs.ts` and is pure; this module owns the hash.
|
|
17
17
|
*
|
|
18
18
|
* What is deliberately *not* here, and is not anywhere: a SQL console, an ER
|
|
19
|
-
* diagram, and grid
|
|
20
|
-
* contract
|
|
19
|
+
* diagram, and grid virtualization. The first is refused by the plugin's
|
|
20
|
+
* contract (no raw SQL, structurally), and the other two are not what a row
|
|
21
21
|
* editor is for.
|
|
22
22
|
*/
|
|
23
23
|
|
|
@@ -119,7 +119,7 @@ function writeHash(): void {
|
|
|
119
119
|
selfNavigation = true
|
|
120
120
|
location.hash = encodeTabs(tabs)
|
|
121
121
|
// `hashchange` fires as a task, so the flag has to survive at least until
|
|
122
|
-
// the next one
|
|
122
|
+
// the next one: a microtask would clear it before the listener ran.
|
|
123
123
|
setTimeout(() => {
|
|
124
124
|
selfNavigation = false
|
|
125
125
|
}, 0)
|
|
@@ -137,7 +137,7 @@ async function confirmDiscard(): Promise<boolean> {
|
|
|
137
137
|
const ok = await confirmChoice({
|
|
138
138
|
verb: 'discard',
|
|
139
139
|
count: session.dirtyRows(),
|
|
140
|
-
table: currentTable() || '
|
|
140
|
+
table: currentTable() || '-',
|
|
141
141
|
detail: 'unsaved edits will be thrown away',
|
|
142
142
|
})
|
|
143
143
|
if (ok) session.clear()
|
|
@@ -202,8 +202,8 @@ function switchView(view: TableView): void {
|
|
|
202
202
|
*
|
|
203
203
|
* The slots matter: `renderChrome` repaints the sidebar and the strip in
|
|
204
204
|
* place, and `#main` is not one of them. Rebuilding the whole shell on every
|
|
205
|
-
* tab-strip change would tear down the grid
|
|
206
|
-
* and the focus inside that editor
|
|
205
|
+
* tab-strip change would tear down the grid (including any editor open in it
|
|
206
|
+
* and the focus inside that editor) every time a staged edit promoted a
|
|
207
207
|
* preview tab, which is precisely when it must not.
|
|
208
208
|
*/
|
|
209
209
|
const sideSlot = el('div', { class: 'side-slot' })
|
|
@@ -263,7 +263,7 @@ async function renderAll(): Promise<void> {
|
|
|
263
263
|
await renderMain()
|
|
264
264
|
}
|
|
265
265
|
|
|
266
|
-
/** Fetch, then render
|
|
266
|
+
/** Fetch, then render: the two never live in one function. */
|
|
267
267
|
async function renderMain(): Promise<void> {
|
|
268
268
|
const main = mainSlot
|
|
269
269
|
const view = activeView(tabs)
|
|
@@ -282,7 +282,7 @@ async function renderMain(): Promise<void> {
|
|
|
282
282
|
}
|
|
283
283
|
|
|
284
284
|
// Structure and Relations render from the schema report the client already
|
|
285
|
-
// holds
|
|
285
|
+
// holds: no request, so no loading state and no failure path.
|
|
286
286
|
if (view.view !== 'data') {
|
|
287
287
|
page.paintMeta(main, view, table)
|
|
288
288
|
return
|
|
@@ -318,7 +318,7 @@ function openRowPanel(
|
|
|
318
318
|
handle.close()
|
|
319
319
|
save(table, id)
|
|
320
320
|
},
|
|
321
|
-
// A real edit is what makes a preview tab permanent
|
|
321
|
+
// A real edit is what makes a preview tab permanent: VS Code's rule, and
|
|
322
322
|
// the one that matters: nobody wants the tab they just typed into replaced
|
|
323
323
|
// by the next single click in the sidebar.
|
|
324
324
|
onDirtyChange: () => {
|
|
@@ -333,14 +333,14 @@ function openRowPanel(
|
|
|
333
333
|
/**
|
|
334
334
|
* Follow a foreign key.
|
|
335
335
|
*
|
|
336
|
-
* One `eq` filter per referenced column, which *is* the row identity
|
|
336
|
+
* One `eq` filter per referenced column, which *is* the row identity, so the
|
|
337
337
|
* destination page holds exactly the referenced row. This used to need a
|
|
338
338
|
* second mechanism: `filters` was a substring `LIKE`, `id=1` also matched `11`,
|
|
339
339
|
* so a link carried a separate `focus` identity and the grid highlighted it.
|
|
340
340
|
* `eq` removed the need and `focus` went with it.
|
|
341
341
|
*
|
|
342
342
|
* The current view is pushed first, so Back returns to where the reference was
|
|
343
|
-
* followed from
|
|
343
|
+
* followed from: the breadcrumb, which stays.
|
|
344
344
|
*/
|
|
345
345
|
async function followFk(
|
|
346
346
|
target: FkTarget,
|
|
@@ -370,14 +370,14 @@ async function boot(): Promise<void> {
|
|
|
370
370
|
adoptUrlKey()
|
|
371
371
|
try {
|
|
372
372
|
// `{access, tables}`: the client has to know its posture *before* it
|
|
373
|
-
// renders, so it never draws an edit affordance it cannot
|
|
373
|
+
// renders, so it never draws an edit affordance it cannot honor.
|
|
374
374
|
state.report = await fetchSchema()
|
|
375
375
|
} catch (error) {
|
|
376
376
|
app.replaceChildren(el('p', { class: 'error', text: messageOf(error) }))
|
|
377
377
|
return
|
|
378
378
|
}
|
|
379
379
|
|
|
380
|
-
// The graph is decoration
|
|
380
|
+
// The graph is decoration: a schema with no declared foreign keys is
|
|
381
381
|
// ordinary, and a failure here must not cost anyone the grid.
|
|
382
382
|
state.graph = await fetchGraph().catch(() => null)
|
|
383
383
|
|
package/src/endpoints/common.ts
CHANGED
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
* The preamble every write endpoint shares, in the order the checks have to
|
|
3
3
|
* happen.
|
|
4
4
|
*
|
|
5
|
-
* 1. **`currentCanWrite()
|
|
5
|
+
* 1. **`currentCanWrite()`**: a `read` caller is refused before the body is
|
|
6
6
|
* even parsed, so nothing about the request can influence the answer.
|
|
7
|
-
* 2. **The body
|
|
8
|
-
* 3. **The table
|
|
9
|
-
* 4. **The identity
|
|
7
|
+
* 2. **The body**: JSON, an object, naming a table.
|
|
8
|
+
* 3. **The table**: 404 if it is not there.
|
|
9
|
+
* 4. **The identity**: 409 if the table has none. A table with no primary
|
|
10
10
|
* key and no all-NOT-NULL unique index is read-only for everyone,
|
|
11
11
|
* including a `write` caller, because there is no way to name one of its
|
|
12
12
|
* rows. See `identity.ts`.
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
* are about the request's own shape rather than about who is asking.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
+
import { errorMsg, pluginLog } from '@bakery-framework/core/logger'
|
|
18
19
|
import { Case, Try } from '@bakery-framework/core/utils'
|
|
19
20
|
import type { JsonResponseData } from '@bakery-framework/core/utils/common'
|
|
20
21
|
import { response } from '@bakery-framework/core/utils/http'
|
|
@@ -37,7 +38,7 @@ export function invalid(errors: FieldError[]): Envelope {
|
|
|
37
38
|
* The request body as an object, or `null`.
|
|
38
39
|
*
|
|
39
40
|
* `req.json()` rather than core's `processBody`, which answers `{}` for a body
|
|
40
|
-
* it could not parse
|
|
41
|
+
* it could not parse: indistinguishable from an empty one, so a truncated
|
|
41
42
|
* upload would be reported as a missing `table` field.
|
|
42
43
|
*/
|
|
43
44
|
export async function readBody(
|
|
@@ -55,7 +56,7 @@ export async function readBody(
|
|
|
55
56
|
/**
|
|
56
57
|
* Find a table by the name the caller used.
|
|
57
58
|
*
|
|
58
|
-
* Raw database name first
|
|
59
|
+
* Raw database name first: that is what `/api/_db/schema` renders and what the
|
|
59
60
|
* grid sends back. The camel spelling is accepted as well, because a script
|
|
60
61
|
* written against a typed schema has `orderItems` where the database has
|
|
61
62
|
* `order_items`, and refusing that would be refusing the ORM's own vocabulary.
|
|
@@ -120,3 +121,21 @@ export async function beginWrite(req: Request): Promise<WriteStart> {
|
|
|
120
121
|
|
|
121
122
|
return { ok: true, table, body }
|
|
122
123
|
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Refuse a request without telling the caller what the database said.
|
|
127
|
+
*
|
|
128
|
+
* Every read endpoint used to answer `400` with `error.message` verbatim, so a
|
|
129
|
+
* malformed `page` came back as SQLite's "datatype mismatch" and a bad lookup
|
|
130
|
+
* key as a JavaScript `TypeError` naming an internal expression. Postgres is
|
|
131
|
+
* worse: its parse errors quote the statement. A caller holding only `read`
|
|
132
|
+
* cannot be handed the query text, and none of it helps a client that has
|
|
133
|
+
* already been told its request was invalid.
|
|
134
|
+
*
|
|
135
|
+
* The message still exists: it goes to the server log with the operation that
|
|
136
|
+
* produced it, which is where an operator can act on it.
|
|
137
|
+
*/
|
|
138
|
+
export function refuse(op: string, error: unknown, status = 400): Envelope {
|
|
139
|
+
pluginLog.EXPLORER_QUERY_ERR({ op, error: errorMsg(error) })
|
|
140
|
+
return response.json.error(status, `${op} failed`)
|
|
141
|
+
}
|
package/src/endpoints/graph.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* `/api/_db/graph` is the map, fetched once: every declared foreign key, every
|
|
5
5
|
* table's identity, and the column worth showing instead of an id.
|
|
6
|
-
* `/api/_db/lookup` resolves actual references
|
|
6
|
+
* `/api/_db/lookup` resolves actual references: **batched, one query per
|
|
7
7
|
* table**, because the shape this replaces is a `fetch` per visible cell, and a
|
|
8
8
|
* fifty-row page with three foreign keys is a hundred and fifty round trips.
|
|
9
9
|
*/
|
|
@@ -15,7 +15,7 @@ import { connection } from '@bakery-framework/orm/connection'
|
|
|
15
15
|
import { qId } from '@bakery-framework/orm/schema-util'
|
|
16
16
|
import { type Identity, introspect, type TableFacts } from '../identity'
|
|
17
17
|
import { overLimit } from '../policy'
|
|
18
|
-
import { findTable, readBody } from './common'
|
|
18
|
+
import { findTable, readBody, refuse } from './common'
|
|
19
19
|
|
|
20
20
|
export async function handleGraph(): Promise<JsonResponseData<unknown>> {
|
|
21
21
|
return await Try.return(
|
|
@@ -23,7 +23,7 @@ export async function handleGraph(): Promise<JsonResponseData<unknown>> {
|
|
|
23
23
|
const [tables, foreignKeys] = await Promise.all([
|
|
24
24
|
introspect(),
|
|
25
25
|
// Composites are already grouped by the adapter, keyed by the tuple
|
|
26
|
-
// rather than by constraint name
|
|
26
|
+
// rather than by constraint name: SQLite reports no name at all.
|
|
27
27
|
connection.getForeignKeys(),
|
|
28
28
|
])
|
|
29
29
|
|
|
@@ -111,7 +111,7 @@ export async function handleLookup(
|
|
|
111
111
|
for (const index of indexes) {
|
|
112
112
|
const key = parsed[index]!.key
|
|
113
113
|
// A ref whose key does not name exactly the identity is skipped
|
|
114
|
-
// rather than widened
|
|
114
|
+
// rather than widened: a partial key is a predicate over more than
|
|
115
115
|
// one row, which is the bug `validateKey` refuses for a write and
|
|
116
116
|
// there is no reason to accept it for a read.
|
|
117
117
|
if (cols.some(col => !(col in key))) continue
|
|
@@ -143,6 +143,6 @@ export async function handleLookup(
|
|
|
143
143
|
|
|
144
144
|
return response.json.success('success', { rows: results })
|
|
145
145
|
},
|
|
146
|
-
|
|
146
|
+
error => refuse('lookup', error),
|
|
147
147
|
)
|
|
148
148
|
}
|