@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.
Files changed (49) hide show
  1. package/package.json +42 -0
  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 +409 -0
  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 +175 -0
  38. package/src/endpoints/rows.ts +435 -0
  39. package/src/identity.ts +401 -0
  40. package/src/index.ts +68 -0
  41. package/src/policy.ts +45 -0
  42. package/src/preview.ts +53 -0
  43. package/src/setup.ts +147 -0
  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
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
+ }