@bakery-framework/plugin-db-explorer 2.0.0-alpha.11
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 +42 -0
- package/src/access.ts +188 -0
- package/src/client/api.ts +279 -0
- package/src/client/bulk.ts +357 -0
- package/src/client/cell.ts +139 -0
- package/src/client/confirm.ts +203 -0
- package/src/client/csv-commit.ts +185 -0
- package/src/client/csv-map.ts +274 -0
- package/src/client/csv-model.ts +420 -0
- package/src/client/csv-pick.ts +54 -0
- package/src/client/csv-preview.ts +89 -0
- package/src/client/csv.ts +104 -0
- package/src/client/dom.ts +164 -0
- package/src/client/edit-session.ts +219 -0
- package/src/client/editors.ts +283 -0
- package/src/client/filter-builder.ts +198 -0
- package/src/client/fk.ts +269 -0
- package/src/client/grid-body.ts +106 -0
- package/src/client/grid-header.ts +65 -0
- package/src/client/grid-rowbar.ts +64 -0
- package/src/client/grid.ts +468 -0
- package/src/client/meta.ts +185 -0
- package/src/client/page.ts +332 -0
- package/src/client/panel.ts +296 -0
- package/src/client/relations.ts +205 -0
- package/src/client/save.ts +205 -0
- package/src/client/sidebar.ts +110 -0
- package/src/client/state.ts +218 -0
- package/src/client/statusbar.ts +130 -0
- package/src/client/structure.ts +234 -0
- package/src/client/tabs.ts +219 -0
- package/src/client/tabstrip.ts +127 -0
- package/src/client.ts +409 -0
- package/src/endpoints/common.ts +122 -0
- package/src/endpoints/graph.ts +148 -0
- package/src/endpoints/import.ts +89 -0
- package/src/endpoints/read.ts +175 -0
- package/src/endpoints/rows.ts +435 -0
- package/src/identity.ts +401 -0
- package/src/index.ts +68 -0
- package/src/policy.ts +45 -0
- package/src/preview.ts +53 -0
- package/src/setup.ts +147 -0
- package/src/shared/coerce.ts +399 -0
- package/src/shared/csv.ts +186 -0
- package/src/shared/filters.ts +200 -0
- package/src/shared/plan.ts +173 -0
- package/src/shell.ts +187 -0
- package/src/validate.ts +295 -0
package/src/client.ts
ADDED
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
/**
|
|
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.
|
|
22
|
+
*/
|
|
23
|
+
|
|
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'
|
|
71
|
+
|
|
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 ?? ''
|
|
105
|
+
}
|
|
106
|
+
|
|
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
|
|
117
|
+
|
|
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
|
+
}
|
|
127
|
+
|
|
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
|
|
145
|
+
}
|
|
146
|
+
|
|
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()
|
|
153
|
+
}
|
|
154
|
+
|
|
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 })
|
|
180
|
+
}
|
|
181
|
+
writeHash()
|
|
182
|
+
await renderAll()
|
|
183
|
+
}
|
|
184
|
+
|
|
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
|
+
}
|
|
191
|
+
|
|
192
|
+
function switchView(view: TableView): void {
|
|
193
|
+
const current = activeView(tabs)
|
|
194
|
+
if (!current) return
|
|
195
|
+
void goto({ ...current, view })
|
|
196
|
+
}
|
|
197
|
+
|
|
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' })
|
|
212
|
+
|
|
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
|
+
},
|
|
250
|
+
})
|
|
251
|
+
|
|
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
|
+
)
|
|
281
|
+
return
|
|
282
|
+
}
|
|
283
|
+
|
|
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
|
|
289
|
+
}
|
|
290
|
+
|
|
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),
|
|
330
|
+
})
|
|
331
|
+
}
|
|
332
|
+
|
|
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()
|
|
371
|
+
try {
|
|
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()
|
|
375
|
+
} catch (error) {
|
|
376
|
+
app.replaceChildren(el('p', { class: 'error', text: messageOf(error) }))
|
|
377
|
+
return
|
|
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
|
+
})
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
void boot().catch(error => {
|
|
408
|
+
notify(messageOf(error), 'error')
|
|
409
|
+
})
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The preamble every write endpoint shares, in the order the checks have to
|
|
3
|
+
* happen.
|
|
4
|
+
*
|
|
5
|
+
* 1. **`currentCanWrite()`** — a `read` caller is refused before the body is
|
|
6
|
+
* even parsed, so nothing about the request can influence the answer.
|
|
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
|
+
* key and no all-NOT-NULL unique index is read-only for everyone,
|
|
11
|
+
* including a `write` caller, because there is no way to name one of its
|
|
12
|
+
* rows. See `identity.ts`.
|
|
13
|
+
*
|
|
14
|
+
* Bounds (413) and validation (400) come after, in each endpoint, because they
|
|
15
|
+
* are about the request's own shape rather than about who is asking.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { Case, Try } from '@bakery-framework/core/utils'
|
|
19
|
+
import type { JsonResponseData } from '@bakery-framework/core/utils/common'
|
|
20
|
+
import { response } from '@bakery-framework/core/utils/http'
|
|
21
|
+
import { currentCanWrite } from '../access'
|
|
22
|
+
import { introspect, type TableFacts } from '../identity'
|
|
23
|
+
import type { FieldError } from '../validate'
|
|
24
|
+
|
|
25
|
+
export type Envelope = JsonResponseData<unknown>
|
|
26
|
+
|
|
27
|
+
export type WriteStart =
|
|
28
|
+
| { ok: true; table: TableFacts; body: Record<string, unknown> }
|
|
29
|
+
| { ok: false; response: Envelope }
|
|
30
|
+
|
|
31
|
+
/** A 400 carrying every field error, never only the first. */
|
|
32
|
+
export function invalid(errors: FieldError[]): Envelope {
|
|
33
|
+
return response.json.error(400, 'Invalid request', { errors })
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The request body as an object, or `null`.
|
|
38
|
+
*
|
|
39
|
+
* `req.json()` rather than core's `processBody`, which answers `{}` for a body
|
|
40
|
+
* it could not parse — indistinguishable from an empty one, so a truncated
|
|
41
|
+
* upload would be reported as a missing `table` field.
|
|
42
|
+
*/
|
|
43
|
+
export async function readBody(
|
|
44
|
+
req: Request,
|
|
45
|
+
): Promise<Record<string, unknown> | null> {
|
|
46
|
+
const body = await Try.return(
|
|
47
|
+
async () => (await req.json()) as unknown,
|
|
48
|
+
null as unknown,
|
|
49
|
+
)
|
|
50
|
+
return typeof body === 'object' && body !== null && !Array.isArray(body)
|
|
51
|
+
? (body as Record<string, unknown>)
|
|
52
|
+
: null
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Find a table by the name the caller used.
|
|
57
|
+
*
|
|
58
|
+
* Raw database name first — that is what `/api/_db/schema` renders and what the
|
|
59
|
+
* grid sends back. The camel spelling is accepted as well, because a script
|
|
60
|
+
* written against a typed schema has `orderItems` where the database has
|
|
61
|
+
* `order_items`, and refusing that would be refusing the ORM's own vocabulary.
|
|
62
|
+
*/
|
|
63
|
+
export function findTable(
|
|
64
|
+
tables: Map<string, TableFacts>,
|
|
65
|
+
name: string,
|
|
66
|
+
): TableFacts | undefined {
|
|
67
|
+
const exact = tables.get(name)
|
|
68
|
+
if (exact) return exact
|
|
69
|
+
const camel = Case.camel(name)
|
|
70
|
+
for (const table of tables.values()) {
|
|
71
|
+
if (table.camel === camel) return table
|
|
72
|
+
}
|
|
73
|
+
return undefined
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function beginWrite(req: Request): Promise<WriteStart> {
|
|
77
|
+
// First, and before the body is read. Convention 2: the guard returns the
|
|
78
|
+
// rejection, and it is the caller's job to return it unchanged.
|
|
79
|
+
if (!currentCanWrite()) {
|
|
80
|
+
return {
|
|
81
|
+
ok: false,
|
|
82
|
+
response: response.json.error(403, 'This session may read but not write'),
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const body = await readBody(req)
|
|
87
|
+
if (!body) {
|
|
88
|
+
return {
|
|
89
|
+
ok: false,
|
|
90
|
+
response: response.json.error(400, 'Expected a JSON object body'),
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const name = body.table
|
|
95
|
+
if (typeof name !== 'string' || !name) {
|
|
96
|
+
return {
|
|
97
|
+
ok: false,
|
|
98
|
+
response: response.json.error(400, 'table is required'),
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const tables = await introspect()
|
|
103
|
+
const table = findTable(tables, name)
|
|
104
|
+
if (!table) {
|
|
105
|
+
return {
|
|
106
|
+
ok: false,
|
|
107
|
+
response: response.json.error(404, `No table named ${name}`),
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (table.identity.mode === 'none') {
|
|
112
|
+
return {
|
|
113
|
+
ok: false,
|
|
114
|
+
response: response.json.error(
|
|
115
|
+
409,
|
|
116
|
+
`${table.name} is read-only: ${table.identity.reason}`,
|
|
117
|
+
),
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return { ok: true, table, body }
|
|
122
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the grid needs to render a foreign key as something other than a number.
|
|
3
|
+
*
|
|
4
|
+
* `/api/_db/graph` is the map, fetched once: every declared foreign key, every
|
|
5
|
+
* table's identity, and the column worth showing instead of an id.
|
|
6
|
+
* `/api/_db/lookup` resolves actual references — **batched, one query per
|
|
7
|
+
* table**, because the shape this replaces is a `fetch` per visible cell, and a
|
|
8
|
+
* fifty-row page with three foreign keys is a hundred and fifty round trips.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { Try } from '@bakery-framework/core/utils'
|
|
12
|
+
import type { JsonResponseData } from '@bakery-framework/core/utils/common'
|
|
13
|
+
import { response } from '@bakery-framework/core/utils/http'
|
|
14
|
+
import { connection } from '@bakery-framework/orm/connection'
|
|
15
|
+
import { qId } from '@bakery-framework/orm/schema-util'
|
|
16
|
+
import { type Identity, introspect, type TableFacts } from '../identity'
|
|
17
|
+
import { overLimit } from '../policy'
|
|
18
|
+
import { findTable, readBody } from './common'
|
|
19
|
+
|
|
20
|
+
export async function handleGraph(): Promise<JsonResponseData<unknown>> {
|
|
21
|
+
return await Try.return(
|
|
22
|
+
async () => {
|
|
23
|
+
const [tables, foreignKeys] = await Promise.all([
|
|
24
|
+
introspect(),
|
|
25
|
+
// Composites are already grouped by the adapter, keyed by the tuple
|
|
26
|
+
// rather than by constraint name — SQLite reports no name at all.
|
|
27
|
+
connection.getForeignKeys(),
|
|
28
|
+
])
|
|
29
|
+
|
|
30
|
+
const identity: Record<string, Identity> = {}
|
|
31
|
+
const labels: Record<string, string | null> = {}
|
|
32
|
+
for (const table of tables.values()) {
|
|
33
|
+
identity[table.name] = table.identity
|
|
34
|
+
labels[table.name] = table.label
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return response.json.success('success', {
|
|
38
|
+
foreignKeys,
|
|
39
|
+
identity,
|
|
40
|
+
labels,
|
|
41
|
+
})
|
|
42
|
+
},
|
|
43
|
+
() => response.json.error(500, 'Failed to read the schema graph'),
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface LookupRef {
|
|
48
|
+
table: string
|
|
49
|
+
key: Record<string, unknown>
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface LookupResult {
|
|
53
|
+
table: string
|
|
54
|
+
key: Record<string, unknown>
|
|
55
|
+
row: Record<string, unknown> | null
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** A stable string for a set of identity values, for matching rows to refs. */
|
|
59
|
+
function fingerprint(cols: readonly string[], row: Record<string, unknown>) {
|
|
60
|
+
// `String(...)` rather than the values themselves: the driver may hand back a
|
|
61
|
+
// `1n` for the `1` that was sent, or a string for a BIGINT, and a lookup that
|
|
62
|
+
// failed to match on that would render every foreign key as "missing".
|
|
63
|
+
return cols.map(col => String(row[col] ?? '\0')).join('\x01')
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function handleLookup(
|
|
67
|
+
req: Request,
|
|
68
|
+
): Promise<JsonResponseData<unknown>> {
|
|
69
|
+
const body = await readBody(req)
|
|
70
|
+
if (!body) return response.json.error(400, 'Expected a JSON object body')
|
|
71
|
+
|
|
72
|
+
const refs = body.refs
|
|
73
|
+
if (!Array.isArray(refs)) {
|
|
74
|
+
return response.json.error(400, 'refs must be an array')
|
|
75
|
+
}
|
|
76
|
+
const over = overLimit('lookupRefs', refs.length)
|
|
77
|
+
if (over) return response.json.error(413, over)
|
|
78
|
+
if (!refs.length) return response.json.success('success', { rows: [] })
|
|
79
|
+
|
|
80
|
+
return await Try.return(
|
|
81
|
+
async () => {
|
|
82
|
+
const tables = await introspect()
|
|
83
|
+
|
|
84
|
+
// Grouped first, queried second. One query per *table*, never one per
|
|
85
|
+
// ref.
|
|
86
|
+
const byTable = new Map<string, { table: TableFacts; refs: number[] }>()
|
|
87
|
+
const parsed: (LookupRef | null)[] = refs.map((ref: any, index) => {
|
|
88
|
+
if (typeof ref?.table !== 'string' || typeof ref?.key !== 'object') {
|
|
89
|
+
return null
|
|
90
|
+
}
|
|
91
|
+
const table = findTable(tables, ref.table)
|
|
92
|
+
if (!table || table.identity.mode === 'none') return null
|
|
93
|
+
const group = byTable.get(table.name) ?? { table, refs: [] }
|
|
94
|
+
group.refs.push(index)
|
|
95
|
+
byTable.set(table.name, group)
|
|
96
|
+
return { table: table.name, key: ref.key as Record<string, unknown> }
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
const results: LookupResult[] = refs.map((ref: any, index) => ({
|
|
100
|
+
table: String(ref?.table ?? ''),
|
|
101
|
+
key: (parsed[index]?.key ?? {}) as Record<string, unknown>,
|
|
102
|
+
row: null,
|
|
103
|
+
}))
|
|
104
|
+
|
|
105
|
+
for (const { table, refs: indexes } of byTable.values()) {
|
|
106
|
+
const cols = table.identity.cols
|
|
107
|
+
const params: unknown[] = []
|
|
108
|
+
const groups: string[] = []
|
|
109
|
+
const wanted = new Map<string, number[]>()
|
|
110
|
+
|
|
111
|
+
for (const index of indexes) {
|
|
112
|
+
const key = parsed[index]!.key
|
|
113
|
+
// A ref whose key does not name exactly the identity is skipped
|
|
114
|
+
// rather than widened — a partial key is a predicate over more than
|
|
115
|
+
// one row, which is the bug `validateKey` refuses for a write and
|
|
116
|
+
// there is no reason to accept it for a read.
|
|
117
|
+
if (cols.some(col => !(col in key))) continue
|
|
118
|
+
groups.push(
|
|
119
|
+
`(${cols
|
|
120
|
+
.map(col => {
|
|
121
|
+
params.push(key[col])
|
|
122
|
+
return `${qId(col)} = ?`
|
|
123
|
+
})
|
|
124
|
+
.join(' AND ')})`,
|
|
125
|
+
)
|
|
126
|
+
const print = fingerprint(cols, key)
|
|
127
|
+
wanted.set(print, [...(wanted.get(print) ?? []), index])
|
|
128
|
+
}
|
|
129
|
+
if (!groups.length) continue
|
|
130
|
+
|
|
131
|
+
const rows = (await connection
|
|
132
|
+
.query(
|
|
133
|
+
`SELECT * FROM ${qId(table.name)} WHERE ${groups.join(' OR ')}`,
|
|
134
|
+
)
|
|
135
|
+
.all(...params)) as Record<string, unknown>[]
|
|
136
|
+
|
|
137
|
+
for (const row of rows) {
|
|
138
|
+
for (const index of wanted.get(fingerprint(cols, row)) ?? []) {
|
|
139
|
+
results[index]!.row = row
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return response.json.success('success', { rows: results })
|
|
145
|
+
},
|
|
146
|
+
(error: any) => response.json.error(400, error?.message ?? 'Lookup failed'),
|
|
147
|
+
)
|
|
148
|
+
}
|