@bakery-framework/plugin-db-explorer 2.0.0-alpha.4 → 2.0.0-alpha.6

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