@bakery-framework/plugin-db-explorer 2.0.0-alpha.5 → 2.0.0-alpha.7

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 (51) hide show
  1. package/package.json +4 -4
  2. package/src/access.ts +188 -0
  3. package/src/client/api.ts +279 -0
  4. package/src/client/bulk.ts +357 -0
  5. package/src/client/cell.ts +139 -0
  6. package/src/client/confirm.ts +203 -0
  7. package/src/client/csv-commit.ts +185 -0
  8. package/src/client/csv-map.ts +274 -0
  9. package/src/client/csv-model.ts +420 -0
  10. package/src/client/csv-pick.ts +54 -0
  11. package/src/client/csv-preview.ts +89 -0
  12. package/src/client/csv.ts +104 -0
  13. package/src/client/dom.ts +164 -0
  14. package/src/client/edit-session.ts +219 -0
  15. package/src/client/editors.ts +283 -0
  16. package/src/client/filter-builder.ts +198 -0
  17. package/src/client/fk.ts +269 -0
  18. package/src/client/grid-body.ts +106 -0
  19. package/src/client/grid-header.ts +65 -0
  20. package/src/client/grid-rowbar.ts +64 -0
  21. package/src/client/grid.ts +468 -0
  22. package/src/client/meta.ts +185 -0
  23. package/src/client/page.ts +332 -0
  24. package/src/client/panel.ts +296 -0
  25. package/src/client/relations.ts +205 -0
  26. package/src/client/save.ts +205 -0
  27. package/src/client/sidebar.ts +110 -0
  28. package/src/client/state.ts +218 -0
  29. package/src/client/statusbar.ts +130 -0
  30. package/src/client/structure.ts +234 -0
  31. package/src/client/tabs.ts +219 -0
  32. package/src/client/tabstrip.ts +127 -0
  33. package/src/client.ts +376 -160
  34. package/src/endpoints/common.ts +122 -0
  35. package/src/endpoints/graph.ts +148 -0
  36. package/src/endpoints/import.ts +89 -0
  37. package/src/endpoints/read.ts +173 -0
  38. package/src/endpoints/rows.ts +435 -0
  39. package/src/identity.ts +391 -0
  40. package/src/index.ts +40 -45
  41. package/src/policy.ts +45 -0
  42. package/src/preview.ts +53 -0
  43. package/src/setup.ts +64 -81
  44. package/src/shared/coerce.ts +399 -0
  45. package/src/shared/csv.ts +186 -0
  46. package/src/shared/filters.ts +200 -0
  47. package/src/shared/plan.ts +173 -0
  48. package/src/shell.ts +187 -0
  49. package/src/validate.ts +295 -0
  50. package/src/credential.ts +0 -26
  51. package/src/endpoints.ts +0 -48
package/src/client.ts CHANGED
@@ -1,193 +1,409 @@
1
1
  /**
2
- * The explorer UI: a table list, a row grid with paging and sorting, nothing
3
- * else. Compiled per request in dev and cached like the dashboard's client;
4
- * kept deliberately small this file is the entire browser side.
2
+ * The explorer's browser entry: boot, then wiring. Nothing else.
3
+ *
4
+ * This file used to be the whole client 197 lines, one `renderTable` that
5
+ * fetched, built a header, built a body and built a pager, and scored **34**
6
+ * against biome's ceiling of 25. Growing that into a data editor would have
7
+ * meant growing that one function, so the shape changed first. The pieces live
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
+ * is left here is the composition: state, routing, and which callback goes
11
+ * where.
12
+ *
13
+ * The layout is the one a database client is expected to have: a table list, a
14
+ * strip of table tabs with VS Code preview semantics, Data / Structure /
15
+ * Relations under it — **one level of nesting and no more** — and a status bar.
16
+ * Tab state lives in `client/tabs.ts` and is pure; this module owns the hash.
17
+ *
18
+ * What is deliberately *not* here, and is not anywhere: a SQL console, an ER
19
+ * diagram, and grid virtualisation. The first is refused by the plugin's
20
+ * contract — no raw SQL, structurally — and the other two are not what a row
21
+ * editor is for.
5
22
  */
6
23
 
7
- type SchemaTable = { name: string; rowCount?: number }
24
+ import {
25
+ adoptUrlKey,
26
+ fetchGraph,
27
+ fetchPage,
28
+ fetchSchema,
29
+ messageOf,
30
+ } from './client/api'
31
+ import { confirmChoice, notify } from './client/confirm'
32
+ import { el } from './client/dom'
33
+ import { EditSession, UndoStack } from './client/edit-session'
34
+ import { equalityFilters } from './client/filter-builder'
35
+ import { FkResolver, type FkTarget } from './client/fk'
36
+ import type { SchemaColumn, SchemaTable } from './client/meta'
37
+ import { Page } from './client/page'
38
+ import { openPanel } from './client/panel'
39
+ import { saveRow } from './client/save'
40
+ import { renderSidebar } from './client/sidebar'
41
+ import {
42
+ type AppState,
43
+ createState,
44
+ defaultView,
45
+ isSystemTable,
46
+ type TableView,
47
+ tableOf,
48
+ type ViewState,
49
+ } from './client/state'
50
+ import {
51
+ activeTab,
52
+ activeView,
53
+ closeTab,
54
+ createTabs,
55
+ decodeTabs,
56
+ encodeTabs,
57
+ openPermanent,
58
+ openPreview,
59
+ promoteActive,
60
+ pruneTabs,
61
+ replaceActiveView,
62
+ selectTab,
63
+ type TabsState,
64
+ } from './client/tabs'
65
+ import {
66
+ renderNewTabPage,
67
+ renderTabStrip,
68
+ renderViewTabs,
69
+ } from './client/tabstrip'
70
+ import type { Filter } from './shared/filters'
8
71
 
9
- type TablePage = {
10
- rows: Record<string, unknown>[]
11
- totalRows?: number
12
- totalPages?: number
72
+ const app = document.getElementById('app')!
73
+
74
+ const state: AppState = createState()
75
+ const session = new EditSession()
76
+ const resolver = new FkResolver()
77
+ const undoStack = new UndoStack(20)
78
+
79
+ let tabs: TabsState = createTabs()
80
+
81
+ /** Set while `writeHash` writes, so the `hashchange` listener ignores itself. */
82
+ let selfNavigation = false
83
+
84
+ const page = new Page(state, session, resolver, undoStack, {
85
+ goto: view => void goto(view),
86
+ saveRow: (table, id) => void save(table, id),
87
+ openRow: openRowPanel,
88
+ followFk: (target, key) => void followFk(target, key),
89
+ reload: () => renderMain(),
90
+ openTable: (table, filters) => void openTable(table, filters),
91
+ onEdit: () => {
92
+ if (!activeTab(tabs)?.preview) return
93
+ tabs = promoteActive(tabs)
94
+ writeHash()
95
+ renderChrome()
96
+ },
97
+ rewind: depth => {
98
+ state.trail = state.trail.slice(0, depth)
99
+ void goto(state.trail[depth] ?? defaultView(currentTable()))
100
+ },
101
+ })
102
+
103
+ function currentTable(): string {
104
+ return activeView(tabs)?.table ?? ''
13
105
  }
14
106
 
15
- const app = document.getElementById('app')!
107
+ function save(table: SchemaTable, id: string): void {
108
+ void saveRow(table, id, {
109
+ session,
110
+ surface: () => page.grid,
111
+ reload: renderMain,
112
+ onDirtyChange: () => page.paintDirty(),
113
+ })
114
+ }
115
+
116
+ // ------------------------------------------------------------------- routing
16
117
 
17
- let currentTable = ''
18
- let currentPage = 1
19
- let sortBy: string | null = null
20
- let sortOrder: 'ASC' | 'DESC' = 'ASC'
21
- const PAGE_SIZE = 50
118
+ function writeHash(): void {
119
+ selfNavigation = true
120
+ location.hash = encodeTabs(tabs)
121
+ // `hashchange` fires as a task, so the flag has to survive at least until
122
+ // the next one — a microtask would clear it before the listener ran.
123
+ setTimeout(() => {
124
+ selfNavigation = false
125
+ }, 0)
126
+ }
22
127
 
23
- // A ?key= opened in the browser is kept for API calls and scrubbed from the
24
- // URL (and so from history) immediately.
25
- const urlKey = new URLSearchParams(location.search).get('db-key')
26
- if (urlKey) {
27
- sessionStorage.setItem('__db_key', urlKey)
28
- const clean = new URL(location.href)
29
- clean.searchParams.delete('db-key')
30
- history.replaceState(null, '', clean)
128
+ /**
129
+ * Ask before losing typed values.
130
+ *
131
+ * The navigation half of the unload guard: losing typed values to a mis-click
132
+ * on a table name is the same loss as losing them to a closed tab, and only one
133
+ * of the two was ever guarded by the browser.
134
+ */
135
+ async function confirmDiscard(): Promise<boolean> {
136
+ if (session.dirtyRows() === 0) return true
137
+ const ok = await confirmChoice({
138
+ verb: 'discard',
139
+ count: session.dirtyRows(),
140
+ table: currentTable() || '—',
141
+ detail: 'unsaved edits will be thrown away',
142
+ })
143
+ if (ok) session.clear()
144
+ return ok
31
145
  }
32
146
 
33
- function keyHeaders(): Record<string, string> {
34
- const key = sessionStorage.getItem('__db_key')
35
- return key ? { 'x-db-key': key } : {}
147
+ /** Move the active tab to a new view of the same table. */
148
+ async function goto(view: ViewState): Promise<void> {
149
+ if (!(await confirmDiscard())) return
150
+ tabs = replaceActiveView(tabs, view)
151
+ writeHash()
152
+ await renderMain()
36
153
  }
37
154
 
38
- async function api<T>(path: string): Promise<T> {
39
- const res = await fetch(`/api/_db/${path}`, { headers: keyHeaders() })
40
- const json = await res.json()
41
- if (json.status < 200 || json.status >= 300) {
42
- throw new Error(json.message || `Request failed (${json.status})`)
43
- }
44
- return json.data as T
45
- }
46
-
47
- function el(tag: string, cls?: string, text?: string): HTMLElement {
48
- const node = document.createElement(tag)
49
- if (cls) node.className = cls
50
- if (text !== undefined) node.textContent = text
51
- return node
52
- }
53
-
54
- function renderShell(tables: string[]) {
55
- app.replaceChildren()
56
-
57
- const side = el('nav', 'side')
58
- side.appendChild(el('h1', 'brand', 'db explorer'))
59
- side.appendChild(el('p', 'note', 'read-only'))
60
-
61
- for (const name of tables.sort()) {
62
- const btn = el('button', 'table-btn', name)
63
- btn.addEventListener('click', () => {
64
- currentTable = name
65
- currentPage = 1
66
- sortBy = null
67
- void renderTable()
68
- side
69
- .querySelectorAll('.table-btn')
70
- .forEach(b => b.classList.toggle('active', b.textContent === name))
71
- })
72
- side.appendChild(btn)
155
+ /** Switch which tab is showing. Each tab keeps its own page, sort and filters. */
156
+ async function select(index: number): Promise<void> {
157
+ if (index === tabs.active) return
158
+ if (!(await confirmDiscard())) return
159
+ tabs = selectTab(tabs, index)
160
+ writeHash()
161
+ await renderAll()
162
+ }
163
+
164
+ /** Single click in the sidebar: a replaceable preview tab. */
165
+ async function preview(table: string): Promise<void> {
166
+ if (!(await confirmDiscard())) return
167
+ tabs = openPreview(tabs, defaultView(table))
168
+ writeHash()
169
+ await renderAll()
170
+ }
171
+
172
+ /** Double click, or a link from Relations: a tab that stays. */
173
+ async function openTable(table: string, filters: Filter[] = []): Promise<void> {
174
+ if (!(await confirmDiscard())) return
175
+ tabs = openPermanent(tabs, { ...defaultView(table), filters })
176
+ // A link that carries filters is a deliberate destination, so it replaces
177
+ // whatever the tab held rather than restoring the tab's old view.
178
+ if (filters.length) {
179
+ tabs = replaceActiveView(tabs, { ...defaultView(table), filters })
73
180
  }
181
+ writeHash()
182
+ await renderAll()
183
+ }
74
184
 
75
- const main = el('main', 'main')
76
- main.id = 'main'
77
- main.appendChild(el('p', 'note', 'Pick a table.'))
185
+ async function close(index: number): Promise<void> {
186
+ if (index === tabs.active && !(await confirmDiscard())) return
187
+ tabs = closeTab(tabs, index)
188
+ writeHash()
189
+ await renderAll()
190
+ }
78
191
 
79
- app.append(side, main)
192
+ function switchView(view: TableView): void {
193
+ const current = activeView(tabs)
194
+ if (!current) return
195
+ void goto({ ...current, view })
80
196
  }
81
197
 
82
- async function renderTable() {
83
- const main = document.getElementById('main')!
84
- main.replaceChildren(el('p', 'note', `loading ${currentTable}…`))
198
+ // ------------------------------------------------------------------ painting
199
+
200
+ /**
201
+ * The four regions, built **once**.
202
+ *
203
+ * The slots matter: `renderChrome` repaints the sidebar and the strip in
204
+ * place, and `#main` is not one of them. Rebuilding the whole shell on every
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
+ * preview tab, which is precisely when it must not.
208
+ */
209
+ const sideSlot = el('div', { class: 'side-slot' })
210
+ const stripSlot = el('div', { class: 'strip-slot' })
211
+ const mainSlot = el('main', { class: 'main', id: 'main' })
85
212
 
86
- const params = new URLSearchParams({
87
- tableName: currentTable,
88
- page: String(currentPage),
89
- pageSize: String(PAGE_SIZE),
90
- sortOrder,
213
+ function renderShell(): void {
214
+ const column = el('div', { class: 'column' })
215
+ column.append(stripSlot, mainSlot, page.status.node)
216
+ app.replaceChildren(sideSlot, column)
217
+ }
218
+
219
+ /** Sidebar and tab strip. Repainted whenever the *set* of tabs changes. */
220
+ function renderChrome(): void {
221
+ sideSlot.replaceChildren(
222
+ renderSidebar({
223
+ tables: state.report?.tables ?? [],
224
+ showSystem: state.showSystem,
225
+ activeTable: activeView(tabs)?.table ?? null,
226
+ onPreview: table => void preview(table),
227
+ onOpen: table => void openTable(table),
228
+ onToggleSystem: show => {
229
+ state.showSystem = show
230
+ renderChrome()
231
+ },
232
+ }),
233
+ )
234
+
235
+ const strip = renderTabStrip({
236
+ tabs,
237
+ onSelect: index => void select(index),
238
+ onPromote: index => {
239
+ tabs = selectTab(tabs, index)
240
+ tabs = promoteActive(tabs)
241
+ writeHash()
242
+ renderChrome()
243
+ },
244
+ onClose: index => void close(index),
245
+ onNew: () => {
246
+ tabs = { ...tabs, active: -1 }
247
+ writeHash()
248
+ void renderAll()
249
+ },
91
250
  })
92
- if (sortBy) params.set('sortBy', sortBy)
93
251
 
94
- let data: TablePage
95
- try {
96
- data = await api<TablePage>(`table-data?${params}`)
97
- } catch (error) {
98
- main.replaceChildren(el('p', 'error', String(error)))
252
+ const current = activeView(tabs)
253
+ stripSlot.replaceChildren(strip)
254
+ if (current) {
255
+ stripSlot.appendChild(
256
+ renderViewTabs({ current: current.view, onSelect: switchView }),
257
+ )
258
+ }
259
+ }
260
+
261
+ async function renderAll(): Promise<void> {
262
+ renderChrome()
263
+ await renderMain()
264
+ }
265
+
266
+ /** Fetch, then render — the two never live in one function. */
267
+ async function renderMain(): Promise<void> {
268
+ const main = mainSlot
269
+ const view = activeView(tabs)
270
+ if (!view) {
271
+ page.paintEmpty(main, renderNewTabPage())
272
+ return
273
+ }
274
+
275
+ const table = tableOf(state, view.table)
276
+ if (!table) {
277
+ page.paintEmpty(
278
+ main,
279
+ el('p', { class: 'note', text: `${view.table} is not in this schema.` }),
280
+ )
99
281
  return
100
282
  }
101
283
 
102
- const rows = data.rows ?? []
103
- main.replaceChildren()
104
-
105
- const head = el('header', 'table-head')
106
- head.appendChild(el('h2', undefined, currentTable))
107
- const meta = el('span', 'note')
108
- meta.textContent =
109
- data.totalRows !== undefined
110
- ? `${data.totalRows} rows · page ${currentPage}${data.totalPages ? ` / ${data.totalPages}` : ''}`
111
- : `page ${currentPage}`
112
- head.appendChild(meta)
113
- main.appendChild(head)
114
-
115
- if (!rows.length) {
116
- main.appendChild(el('p', 'note', 'No rows on this page.'))
117
- } else {
118
- const cols = Object.keys(rows[0])
119
- const table = el('table', 'grid')
120
- const thead = el('thead')
121
- const headRow = el('tr')
122
- for (const col of cols) {
123
- const th = el('th', undefined, col)
124
- if (col === sortBy) th.textContent += sortOrder === 'ASC' ? ' ↑' : ' ↓'
125
- // rowid is in the payload but not in the sortable allow-list server-side;
126
- // offering a header click that silently no-ops reads as a bug.
127
- if (col === 'rowid') {
128
- headRow.appendChild(th)
129
- continue
130
- }
131
- th.addEventListener('click', () => {
132
- sortOrder = sortBy === col && sortOrder === 'ASC' ? 'DESC' : 'ASC'
133
- sortBy = col
134
- void renderTable()
135
- })
136
- headRow.appendChild(th)
137
- }
138
- thead.appendChild(headRow)
139
- table.appendChild(thead)
140
-
141
- const tbody = el('tbody')
142
- for (const row of rows) {
143
- const tr = el('tr')
144
- for (const col of cols) {
145
- const value = row[col]
146
- tr.appendChild(
147
- el(
148
- 'td',
149
- value === null ? 'null' : undefined,
150
- value === null ? 'NULL' : String(value),
151
- ),
152
- )
153
- }
154
- tbody.appendChild(tr)
155
- }
156
- table.appendChild(tbody)
157
-
158
- const scroller = el('div', 'scroll')
159
- scroller.appendChild(table)
160
- main.appendChild(scroller)
284
+ // Structure and Relations render from the schema report the client already
285
+ // holds — no request, so no loading state and no failure path.
286
+ if (view.view !== 'data') {
287
+ page.paintMeta(main, view, table)
288
+ return
161
289
  }
162
290
 
163
- const pager = el('footer', 'pager')
164
- const prev = el('button', undefined, '← prev') as HTMLButtonElement
165
- prev.disabled = currentPage <= 1
166
- prev.addEventListener('click', () => {
167
- currentPage--
168
- void renderTable()
169
- })
170
- const next = el('button', undefined, 'next →') as HTMLButtonElement
171
- next.disabled =
172
- data.totalPages !== undefined
173
- ? currentPage >= data.totalPages
174
- : rows.length < PAGE_SIZE
175
- next.addEventListener('click', () => {
176
- currentPage++
177
- void renderTable()
291
+ main.replaceChildren(
292
+ el('p', { class: 'note', text: `loading ${table.name}…` }),
293
+ )
294
+ try {
295
+ const answer = await fetchPage(view)
296
+ page.paint(main, view, table, answer.data, answer.ms)
297
+ } catch (error) {
298
+ main.replaceChildren(el('p', { class: 'error', text: messageOf(error) }))
299
+ }
300
+ }
301
+
302
+ // ----------------------------------------------------------- panel and links
303
+
304
+ function openRowPanel(
305
+ table: SchemaTable,
306
+ columns: SchemaColumn[],
307
+ editable: boolean,
308
+ row: Record<string, unknown>,
309
+ ): void {
310
+ const handle = openPanel({
311
+ table,
312
+ columns,
313
+ row,
314
+ editable,
315
+ graph: state.graph,
316
+ session,
317
+ onSave: id => {
318
+ handle.close()
319
+ save(table, id)
320
+ },
321
+ // A real edit is what makes a preview tab permanent — VS Code's rule, and
322
+ // the one that matters: nobody wants the tab they just typed into replaced
323
+ // by the next single click in the sidebar.
324
+ onDirtyChange: () => {
325
+ tabs = promoteActive(tabs)
326
+ page.paintDirty()
327
+ renderChrome()
328
+ },
329
+ onNavigate: (name, filters) => void openTable(name, filters),
178
330
  })
179
- pager.append(prev, next)
180
- main.appendChild(pager)
181
331
  }
182
332
 
183
- async function boot() {
333
+ /**
334
+ * Follow a foreign key.
335
+ *
336
+ * One `eq` filter per referenced column, which *is* the row identity — so the
337
+ * destination page holds exactly the referenced row. This used to need a
338
+ * second mechanism: `filters` was a substring `LIKE`, `id=1` also matched `11`,
339
+ * so a link carried a separate `focus` identity and the grid highlighted it.
340
+ * `eq` removed the need and `focus` went with it.
341
+ *
342
+ * The current view is pushed first, so Back returns to where the reference was
343
+ * followed from — the breadcrumb, which stays.
344
+ */
345
+ async function followFk(
346
+ target: FkTarget,
347
+ key: Record<string, unknown>,
348
+ ): Promise<void> {
349
+ const current = activeView(tabs)
350
+ if (current) state.trail.push(current)
351
+ await openTable(target.refTable, equalityFilters(key))
352
+ }
353
+
354
+ // ------------------------------------------------------------------ plumbing
355
+
356
+ /**
357
+ * The unload guard.
358
+ *
359
+ * `preventDefault` is the modern spelling and `returnValue` is what Safari
360
+ * still reads. Both, because losing an edit to a closed tab is the failure this
361
+ * exists for and the second line costs a line.
362
+ */
363
+ function guardUnload(event: BeforeUnloadEvent): void {
364
+ if (session.dirtyRows() === 0) return
365
+ event.preventDefault()
366
+ event.returnValue = ''
367
+ }
368
+
369
+ async function boot(): Promise<void> {
370
+ adoptUrlKey()
184
371
  try {
185
- // `getSchema` answers a list of table descriptors, not a keyed object.
186
- const schema = await api<SchemaTable[]>('schema')
187
- renderShell((schema ?? []).map(t => t.name).filter(Boolean))
372
+ // `{access, tables}`: the client has to know its posture *before* it
373
+ // renders, so it never draws an edit affordance it cannot honour.
374
+ state.report = await fetchSchema()
188
375
  } catch (error) {
189
- app.replaceChildren(el('p', 'error', String(error)))
376
+ app.replaceChildren(el('p', { class: 'error', text: messageOf(error) }))
377
+ return
190
378
  }
379
+
380
+ // The graph is decoration — a schema with no declared foreign keys is
381
+ // ordinary, and a failure here must not cost anyone the grid.
382
+ state.graph = await fetchGraph().catch(() => null)
383
+
384
+ // A hash can name a table that has since been dropped; pruning here means
385
+ // the strip never carries a tab that can only ever render an error.
386
+ const known = new Set((state.report.tables ?? []).map(table => table.name))
387
+ tabs = pruneTabs(decodeTabs(location.hash), known)
388
+
389
+ // A system table reached by link opens with the sidebar toggle already on,
390
+ // so the tab it lands in is visible in the list beside it rather than
391
+ // appearing to have come from nowhere.
392
+ if (tabs.tabs.some(tab => isSystemTable(tab.view.table))) {
393
+ state.showSystem = true
394
+ }
395
+
396
+ renderShell()
397
+ await renderAll()
398
+
399
+ window.addEventListener('beforeunload', guardUnload)
400
+ window.addEventListener('hashchange', () => {
401
+ if (selfNavigation) return
402
+ tabs = pruneTabs(decodeTabs(location.hash), known)
403
+ void renderAll()
404
+ })
191
405
  }
192
406
 
193
- void boot()
407
+ void boot().catch(error => {
408
+ notify(messageOf(error), 'error')
409
+ })