@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
@@ -0,0 +1,468 @@
1
+ /**
2
+ * The editable grid: which node holds which value, and where focus is.
3
+ *
4
+ * Two rules keep this file's functions under biome's complexity ceiling, and
5
+ * they are worth stating because the old `renderTable` broke both and scored
6
+ * 34 with a fraction of the behaviour:
7
+ *
8
+ * 1. **No function both fetches and renders.** Everything here takes rows it
9
+ * was handed. `api.ts` does the asking.
10
+ * 2. **Every loop body is a named function.** `each()` from `dom.ts` takes the
11
+ * factory; an inline body would fold its branches into the caller's score.
12
+ *
13
+ * Three neighbours carry the parts that do not need the cursor:
14
+ * `grid-header.ts` builds the heading row, `grid-body.ts` paints a cell's
15
+ * contents, and `grid-rowbar.ts` owns the per-row save strip. The editing model
16
+ * itself is in `edit-session.ts` and the keyboard model in `cell.ts`, both pure
17
+ * and both tested. What is left here genuinely needs a DOM.
18
+ *
19
+ * `focus` used to live here too — a row identity carried alongside the filters,
20
+ * because a substring `LIKE` could not name a row. With `eq` in the filter
21
+ * vocabulary a foreign-key jump is an ordinary filter, and the highlight, the
22
+ * `focusedMissing()` note and the whole concept are gone.
23
+ */
24
+
25
+ import { afterCommit, type CellAction, type CellState, keyOnCell } from './cell'
26
+ import { box, each, el, on, setBusy } from './dom'
27
+ import type { EditSession } from './edit-session'
28
+ import { rowId } from './edit-session'
29
+ import { createEditor, type EditorHandle } from './editors'
30
+ import { type FkResolver, type FkTarget, fkMapFor } from './fk'
31
+ import { type CellPaintContext, paintCell } from './grid-body'
32
+ import { buildHead } from './grid-header'
33
+ import {
34
+ buildRowBar,
35
+ paintRowBar,
36
+ type RowBar,
37
+ setRowBarMessage,
38
+ } from './grid-rowbar'
39
+ import type { SchemaColumn, SchemaGraph, SchemaTable } from './meta'
40
+
41
+ export interface GridContext {
42
+ table: SchemaTable
43
+ columns: SchemaColumn[]
44
+ rows: Record<string, unknown>[]
45
+ editable: boolean
46
+ graph: SchemaGraph | null
47
+ session: EditSession
48
+ resolver: FkResolver
49
+ sortBy: string | null
50
+ sortOrder: 'ASC' | 'DESC'
51
+ onSort: (column: string) => void
52
+ onSaveRow: (id: string) => void
53
+ onOpenRow: (row: Record<string, unknown>) => void
54
+ onFollowFk: (target: FkTarget, key: Record<string, unknown>) => void
55
+ onSelectionChange: (selected: number) => void
56
+ onDirtyChange: () => void
57
+ }
58
+
59
+ interface RowSlot {
60
+ id: string | null
61
+ row: Record<string, unknown>
62
+ tr: HTMLTableRowElement
63
+ cells: HTMLTableCellElement[]
64
+ bar: RowBar
65
+ check: HTMLInputElement | null
66
+ }
67
+
68
+ export class Grid {
69
+ readonly node: HTMLElement
70
+ private readonly slots: RowSlot[] = []
71
+ private readonly body: HTMLTableSectionElement
72
+ private readonly paintCtx: CellPaintContext
73
+ private cursor = { row: 0, col: 0 }
74
+ private editor: EditorHandle | null = null
75
+ private editing: { row: number; col: number } | null = null
76
+ readonly selected = new Set<string>()
77
+
78
+ constructor(private readonly ctx: GridContext) {
79
+ this.paintCtx = {
80
+ table: ctx.table.name,
81
+ graph: ctx.graph,
82
+ // Once per grid, not once per cell per paint — see `fkMapFor`.
83
+ fks: fkMapFor(ctx.graph, ctx.table.name),
84
+ resolver: ctx.resolver,
85
+ onFollowFk: ctx.onFollowFk,
86
+ }
87
+
88
+ const table = el('table', { class: 'grid' })
89
+ table.appendChild(
90
+ buildHead({
91
+ columns: ctx.columns,
92
+ editable: ctx.editable,
93
+ sortBy: ctx.sortBy,
94
+ sortOrder: ctx.sortOrder,
95
+ onSort: ctx.onSort,
96
+ onToggleAll: checked => this.setAllSelected(checked),
97
+ }),
98
+ )
99
+ this.body = el('tbody')
100
+ table.appendChild(this.body)
101
+ each(this.body, ctx.rows, (row, index) => this.buildRow(row, index))
102
+ this.node = box('scroll', table)
103
+ on(this.node, 'keydown', event => this.onKeyDown(event as KeyboardEvent))
104
+ }
105
+
106
+ // -------------------------------------------------------------------- rows
107
+
108
+ private buildRow(row: Record<string, unknown>, index: number): Node {
109
+ const id = rowId(row, this.ctx.table.identity.cols)
110
+ const tr = el('tr')
111
+ const check = this.buildPick(tr, row, id)
112
+ const cells: HTMLTableCellElement[] = []
113
+ each(tr, this.ctx.columns, (column, col) => {
114
+ const td = this.buildCell(row, index, column, col)
115
+ cells.push(td)
116
+ return td
117
+ })
118
+
119
+ const bar = buildRowBar(this.ctx.columns.length + 1, {
120
+ onSave: () => this.saveRow(index),
121
+ onRevert: () => this.revertRow(index),
122
+ })
123
+ this.slots[index] = { id, row, tr, cells, bar, check }
124
+
125
+ // A fragment so one `each` callback can add two rows: the data row and the
126
+ // save/revert bar that lives under it.
127
+ const fragment = document.createDocumentFragment()
128
+ fragment.append(tr, bar.tr)
129
+ return fragment
130
+ }
131
+
132
+ /** Selection checkbox (when editable) and the panel opener (always). */
133
+ private buildPick(
134
+ tr: HTMLTableRowElement,
135
+ row: Record<string, unknown>,
136
+ id: string | null,
137
+ ): HTMLInputElement | null {
138
+ const td = el('td', { class: 'pick' })
139
+ let check: HTMLInputElement | null = null
140
+ if (this.ctx.editable) {
141
+ check = el('input', { attrs: { 'aria-label': 'select row' } })
142
+ check.type = 'checkbox'
143
+ check.disabled = id === null
144
+ on(check, 'change', () => this.setSelected(id, check!.checked))
145
+ td.appendChild(check)
146
+ }
147
+ const open = el('button', {
148
+ class: 'btn',
149
+ text: '⋯',
150
+ title: 'open this row',
151
+ })
152
+ open.type = 'button'
153
+ on(open, 'click', () => this.ctx.onOpenRow(row))
154
+ td.appendChild(open)
155
+ tr.appendChild(td)
156
+ return check
157
+ }
158
+
159
+ // ------------------------------------------------------------------- cells
160
+
161
+ private buildCell(
162
+ row: Record<string, unknown>,
163
+ rowIndex: number,
164
+ column: SchemaColumn,
165
+ col: number,
166
+ ): HTMLTableCellElement {
167
+ const td = el('td', { attrs: { tabindex: -1 } })
168
+ on(td, 'click', () => this.focusCell(rowIndex, col))
169
+ on(td, 'dblclick', () => this.requestEdit(rowIndex, col))
170
+ this.repaintCell(rowIndex, col, td, row, column)
171
+ return td
172
+ }
173
+
174
+ /**
175
+ * Render one cell from the session's current view of it.
176
+ *
177
+ * Called on build, after every stage, and after an editor closes — so a
178
+ * staged value and a saved value take exactly the same path.
179
+ */
180
+ private repaintCell(
181
+ rowIndex: number,
182
+ col: number,
183
+ node?: HTMLTableCellElement,
184
+ row?: Record<string, unknown>,
185
+ column?: SchemaColumn,
186
+ ): void {
187
+ const slot = this.slots[rowIndex]
188
+ const td = node ?? slot?.cells[col]
189
+ const source = row ?? slot?.row
190
+ const meta = column ?? this.ctx.columns[col]
191
+ if (!td || !source || !meta) return
192
+
193
+ const id = slot?.id ?? rowId(source, this.ctx.table.identity.cols)
194
+ const value = id
195
+ ? this.ctx.session.value(id, meta.name, source[meta.name])
196
+ : source[meta.name]
197
+ const staged = id ? this.ctx.session.isStaged(id, meta.name) : false
198
+ paintCell(td, this.paintCtx, source, meta, value, staged)
199
+ }
200
+
201
+ // ----------------------------------------------------------------- cursor
202
+
203
+ private focusCell(row: number, col: number): void {
204
+ if (this.editing) this.commitEditor('none')
205
+ this.cursor = { row, col }
206
+ const td = this.slots[row]?.cells[col]
207
+ if (!td) return
208
+ for (const other of this.body.querySelectorAll('td.at')) {
209
+ other.classList.remove('at')
210
+ }
211
+ td.classList.add('at')
212
+ td.focus()
213
+ }
214
+
215
+ private cellState(): CellState {
216
+ return {
217
+ row: this.cursor.row,
218
+ col: this.cursor.col,
219
+ rows: this.slots.length,
220
+ cols: this.ctx.columns.length,
221
+ editing: this.editing !== null,
222
+ editable: this.ctx.editable,
223
+ }
224
+ }
225
+
226
+ private onKeyDown(event: KeyboardEvent): void {
227
+ const action = keyOnCell(event, this.cellState())
228
+ if (action.type === 'none') return
229
+ event.preventDefault()
230
+ this.apply(action)
231
+ }
232
+
233
+ private apply(action: CellAction): void {
234
+ const handlers: Record<CellAction['type'], () => void> = {
235
+ none: () => {},
236
+ move: () => {
237
+ const move = action as { row: number; col: number }
238
+ this.focusCell(move.row, move.col)
239
+ },
240
+ edit: () => this.requestEdit(this.cursor.row, this.cursor.col),
241
+ cancel: () => this.cancelEditor(),
242
+ null: () => this.stageNull(),
243
+ commit: () => this.commitEditor((action as { move: 'down' }).move),
244
+ }
245
+ handlers[action.type]()
246
+ }
247
+
248
+ // ---------------------------------------------------------------- editing
249
+
250
+ /**
251
+ * Open an editor — or explain why not.
252
+ *
253
+ * A double-click on a read-only table says what is wrong *here*, at the
254
+ * moment the user asked. The alternative is a grid that looks editable and
255
+ * refuses at save time, after the typing.
256
+ */
257
+ private requestEdit(rowIndex: number, col: number): void {
258
+ this.focusCell(rowIndex, col)
259
+ if (!this.ctx.editable) {
260
+ this.setRowMessage(
261
+ rowIndex,
262
+ this.ctx.table.reason ?? 'this table is read-only',
263
+ )
264
+ return
265
+ }
266
+ const slot = this.slots[rowIndex]
267
+ if (!slot?.id) {
268
+ this.setRowMessage(
269
+ rowIndex,
270
+ 'this row carries no identity, so it cannot be addressed',
271
+ )
272
+ return
273
+ }
274
+ this.openEditor(rowIndex, col, slot)
275
+ }
276
+
277
+ private openEditor(rowIndex: number, col: number, slot: RowSlot): void {
278
+ const column = this.ctx.columns[col]
279
+ if (!column || !slot.id) return
280
+ const current = this.ctx.session.value(
281
+ slot.id,
282
+ column.name,
283
+ slot.row[column.name],
284
+ )
285
+ const handle = createEditor(column, current, {
286
+ // The grid reads the editor at commit time instead of tracking every
287
+ // keystroke, which is what makes Escape a true revert: an uncommitted
288
+ // value was never anywhere the session could see it.
289
+ onInput: () => {},
290
+ onKey: event => this.onKeyDown(event),
291
+ })
292
+ // Blur stages; it never saves. A click into another cell must not be a
293
+ // write, and a multi-column edit must stay one statement.
294
+ //
295
+ // `relatedTarget` is checked because `focusout` also fires for movement
296
+ // *inside* the editor — tabbing from the input to the null toggle is one —
297
+ // and committing there would close the editor the user is still in.
298
+ handle.node.addEventListener('focusout', event => {
299
+ const next = (event as FocusEvent).relatedTarget
300
+ if (next instanceof Node && handle.node.contains(next)) return
301
+ this.stageOnBlur(rowIndex, col)
302
+ })
303
+ const td = slot.cells[col]
304
+ if (!td) return
305
+ td.replaceChildren(handle.node)
306
+ td.classList.add('editing')
307
+ this.editor = handle
308
+ this.editing = { row: rowIndex, col }
309
+ handle.focus()
310
+ }
311
+
312
+ private stageOnBlur(rowIndex: number, col: number): void {
313
+ if (
314
+ !this.editing ||
315
+ this.editing.row !== rowIndex ||
316
+ this.editing.col !== col
317
+ )
318
+ return
319
+ this.commitEditor('none')
320
+ }
321
+
322
+ private commitEditor(move: 'down' | 'right' | 'left' | 'none'): void {
323
+ const at = this.editing
324
+ if (!at) return
325
+ const slot = this.slots[at.row]
326
+ const column = this.ctx.columns[at.col]
327
+ // Read before closing: the editor is the only holder of the typed value.
328
+ const value = this.editor?.read()
329
+ this.closeEditor()
330
+ if (slot?.id && column) {
331
+ this.ctx.session.stage(slot.id, slot.row, column.name, value)
332
+ this.repaintCell(at.row, at.col)
333
+ this.refreshBar(at.row)
334
+ this.ctx.onDirtyChange()
335
+ }
336
+ const next = afterCommit(
337
+ { ...this.cellState(), ...at, editing: false },
338
+ move,
339
+ )
340
+ if (next.type === 'move') this.focusCell(next.row, next.col)
341
+ }
342
+
343
+ /** Escape: the editor is dropped unread and the cell repaints from the buffer. */
344
+ private cancelEditor(): void {
345
+ const at = this.editing
346
+ if (!at) return
347
+ this.closeEditor()
348
+ this.repaintCell(at.row, at.col)
349
+ this.focusCell(at.row, at.col)
350
+ }
351
+
352
+ private closeEditor(): void {
353
+ const at = this.editing
354
+ this.editing = null
355
+ this.editor = null
356
+ if (at) this.slots[at.row]?.cells[at.col]?.classList.remove('editing')
357
+ }
358
+
359
+ /** Delete on a selected cell: SQL NULL, not the empty string. */
360
+ private stageNull(): void {
361
+ const slot = this.slots[this.cursor.row]
362
+ const column = this.ctx.columns[this.cursor.col]
363
+ if (!slot?.id || !column) return
364
+ this.ctx.session.stage(slot.id, slot.row, column.name, null)
365
+ this.repaintCell(this.cursor.row, this.cursor.col)
366
+ this.refreshBar(this.cursor.row)
367
+ this.ctx.onDirtyChange()
368
+ }
369
+
370
+ // ------------------------------------------------------------- the row bar
371
+
372
+ refreshBar(rowIndex: number): void {
373
+ const slot = this.slots[rowIndex]
374
+ if (!slot) return
375
+ const changed = slot.id ? this.ctx.session.changedColumns(slot.id) : []
376
+ paintRowBar(slot.bar, changed)
377
+ }
378
+
379
+ private saveRow(rowIndex: number): void {
380
+ const slot = this.slots[rowIndex]
381
+ if (slot?.id) this.ctx.onSaveRow(slot.id)
382
+ }
383
+
384
+ private revertRow(rowIndex: number): void {
385
+ const slot = this.slots[rowIndex]
386
+ if (!slot?.id) return
387
+ this.ctx.session.drop(slot.id)
388
+ this.repaintRow(rowIndex)
389
+ this.ctx.onDirtyChange()
390
+ }
391
+
392
+ repaintRow(rowIndex: number): void {
393
+ const slot = this.slots[rowIndex]
394
+ if (!slot) return
395
+ this.ctx.columns.forEach((_column, col) => {
396
+ this.repaintCell(rowIndex, col)
397
+ })
398
+ this.refreshBar(rowIndex)
399
+ slot.bar.message.textContent = ''
400
+ }
401
+
402
+ /** Where a save's failure is reported: under the row it belongs to. */
403
+ setRowMessage(rowIndex: number, message: string): void {
404
+ const slot = this.slots[rowIndex]
405
+ if (!slot) return
406
+ setRowBarMessage(slot.bar, message)
407
+ }
408
+
409
+ setRowBusy(rowIndex: number, busy: boolean): void {
410
+ const slot = this.slots[rowIndex]
411
+ if (!slot) return
412
+ setBusy(slot.tr, busy)
413
+ for (const input of slot.tr.querySelectorAll(
414
+ 'input,button,select,textarea',
415
+ )) {
416
+ ;(input as HTMLInputElement).disabled = busy
417
+ }
418
+ }
419
+
420
+ indexOfRow(id: string): number {
421
+ return this.slots.findIndex(slot => slot?.id === id)
422
+ }
423
+
424
+ rowAt(index: number): Record<string, unknown> | undefined {
425
+ return this.slots[index]?.row
426
+ }
427
+
428
+ /** The row under the cursor — what a keyboard-only user means by "this row". */
429
+ focusedRow(): Record<string, unknown> | undefined {
430
+ return this.slots[this.cursor.row]?.row
431
+ }
432
+
433
+ /** The rows the checkboxes name, as identity objects for a write. */
434
+ selectedKeys(): Record<string, unknown>[] {
435
+ const cols = this.ctx.table.identity.cols
436
+ const keys: Record<string, unknown>[] = []
437
+ for (const slot of this.slots) {
438
+ if (!slot?.id || !this.selected.has(slot.id)) continue
439
+ const key: Record<string, unknown> = {}
440
+ for (const column of cols) key[column] = slot.row[column]
441
+ keys.push(key)
442
+ }
443
+ return keys
444
+ }
445
+
446
+ selectedRows(): Record<string, unknown>[] {
447
+ return this.slots
448
+ .filter(slot => slot?.id && this.selected.has(slot.id))
449
+ .map(slot => slot.row)
450
+ }
451
+
452
+ private setSelected(id: string | null, on: boolean): void {
453
+ if (!id) return
454
+ if (on) this.selected.add(id)
455
+ else this.selected.delete(id)
456
+ this.ctx.onSelectionChange(this.selected.size)
457
+ }
458
+
459
+ private setAllSelected(on: boolean): void {
460
+ for (const slot of this.slots) {
461
+ if (!slot?.id || !slot.check) continue
462
+ slot.check.checked = on
463
+ if (on) this.selected.add(slot.id)
464
+ else this.selected.delete(slot.id)
465
+ }
466
+ this.ctx.onSelectionChange(this.selected.size)
467
+ }
468
+ }
@@ -0,0 +1,185 @@
1
+ /**
2
+ * What the server said about a column, and the four decisions the grid makes
3
+ * from it.
4
+ *
5
+ * **Pure — no DOM.** Every branch the editor would otherwise take on a column's
6
+ * type is resolved here, once, into a `WidgetKind`, and every consumer
7
+ * dispatches through a record of small functions rather than an if-chain. A
8
+ * record of five functions has no cognitive complexity; five `else if`s have
9
+ * one arm each, and that is the whole reason `renderTable` in the old client
10
+ * scored 34.
11
+ *
12
+ * The shapes below mirror `endpoints/read.ts` exactly. They are re-declared
13
+ * rather than imported because that module reaches for the ORM and for
14
+ * `node:async_hooks` through its neighbours, and this one is compiled into the
15
+ * browser bundle.
16
+ */
17
+
18
+ import type { ColumnKind, ColumnMeta } from '../shared/coerce'
19
+ import { normalize } from '../shared/plan'
20
+
21
+ export interface SchemaColumn {
22
+ name: string
23
+ type: string
24
+ notnull: boolean
25
+ pk: boolean
26
+ kind: string
27
+ nullable: boolean
28
+ length?: number
29
+ enum?: readonly string[]
30
+ hasDefault: boolean
31
+ autoIncrement?: boolean
32
+ }
33
+
34
+ export interface Identity {
35
+ mode: 'pk' | 'unique' | 'none'
36
+ cols: string[]
37
+ reason?: string
38
+ }
39
+
40
+ export interface SchemaIndex {
41
+ name: string
42
+ type: string
43
+ cols: string[]
44
+ }
45
+
46
+ export interface SchemaTable {
47
+ name: string
48
+ rowCount: number
49
+ columns: SchemaColumn[]
50
+ identity: Identity
51
+ /** Optional so a fixture — and an older server — need not carry them. */
52
+ indexes?: SchemaIndex[]
53
+ isView?: boolean
54
+ writable: boolean
55
+ reason?: string
56
+ }
57
+
58
+ export interface SchemaReport {
59
+ access: 'read' | 'write' | false
60
+ tables: SchemaTable[]
61
+ }
62
+
63
+ export interface TablePage {
64
+ rows: Record<string, unknown>[]
65
+ totalRows?: number
66
+ totalPages?: number
67
+ page?: number
68
+ pageSize?: number
69
+ }
70
+
71
+ export interface ForeignKeyInfo {
72
+ table: string
73
+ cols: string[]
74
+ refTable: string
75
+ refCols: string[]
76
+ name?: string
77
+ }
78
+
79
+ export interface SchemaGraph {
80
+ foreignKeys: Record<string, ForeignKeyInfo>
81
+ identity: Record<string, Identity>
82
+ labels: Record<string, string | null>
83
+ }
84
+
85
+ /** Which editor a column gets. The discriminant every dispatch keys on. */
86
+ export type WidgetKind = 'enum' | 'boolean' | 'date' | 'json' | 'text'
87
+
88
+ /**
89
+ * `enum` first, because it is a constraint on a `string` column rather than a
90
+ * kind of its own — the server reports `kind: 'string'` with an `enum` list,
91
+ * and a free-text box over a checked list is a guaranteed round trip to a
92
+ * `not_in_enum` error.
93
+ */
94
+ export function columnKind(column: SchemaColumn): WidgetKind {
95
+ if (column.enum?.length) return 'enum'
96
+ if (column.kind === 'boolean') return 'boolean'
97
+ if (column.kind === 'date') return 'date'
98
+ if (column.kind === 'json') return 'json'
99
+ return 'text'
100
+ }
101
+
102
+ /**
103
+ * The `ColumnMeta` `coerceValue` takes.
104
+ *
105
+ * The wire shape and the coercer's shape are nearly the same object and are
106
+ * deliberately not the same type: `kind` crosses the wire as a plain `string`
107
+ * because JSON has no unions, and this is the one place it is narrowed back.
108
+ */
109
+ export function columnMeta(column: SchemaColumn): ColumnMeta {
110
+ return {
111
+ kind: column.kind as ColumnKind,
112
+ nullable: column.nullable,
113
+ length: column.length,
114
+ enum: column.enum,
115
+ hasDefault: column.hasDefault,
116
+ primary: column.pk,
117
+ autoIncrement: column.autoIncrement,
118
+ }
119
+ }
120
+
121
+ /**
122
+ * A cell's text.
123
+ *
124
+ * `NULL` is rendered as the word and styled as absent, which is the
125
+ * distinction the whole editor turns on: an empty string is a value and a
126
+ * blank cell would make the two look identical. Binary is summarised rather
127
+ * than dumped — a blob rendered as its bytes is thousands of characters of
128
+ * noise that also makes the row unselectable.
129
+ */
130
+ export function cellText(value: unknown): string {
131
+ if (value === null || value === undefined) return 'NULL'
132
+ if (value instanceof Uint8Array) return `${value.byteLength} bytes`
133
+ if (typeof value === 'object') return safeJson(value)
134
+ return String(value)
135
+ }
136
+
137
+ function safeJson(value: unknown): string {
138
+ try {
139
+ return JSON.stringify(value) ?? String(value)
140
+ } catch {
141
+ // A cycle or a BigInt. The cell still has to render something, and the
142
+ // failure is cosmetic — the value itself is untouched.
143
+ return String(value)
144
+ }
145
+ }
146
+
147
+ /** Class names for a rendered cell: the base, plus what the value is. */
148
+ export function cellClass(value: unknown, column: SchemaColumn): string {
149
+ const classes = ['cell', `k-${columnKind(column)}`]
150
+ if (value === null || value === undefined) classes.push('null')
151
+ if (column.pk) classes.push('pk')
152
+ if (typeof value === 'number' || typeof value === 'bigint') {
153
+ classes.push('num')
154
+ }
155
+ return classes.join(' ')
156
+ }
157
+
158
+ export interface CellProps {
159
+ text: string
160
+ className: string
161
+ /** The untruncated value, when the cell shows less than all of it. */
162
+ title?: string
163
+ }
164
+
165
+ /** Everything a cell needs, as data. The DOM half only assigns these three. */
166
+ export function cellProps(value: unknown, column: SchemaColumn): CellProps {
167
+ const text = cellText(value)
168
+ return {
169
+ text,
170
+ className: cellClass(value, column),
171
+ title: text.length > 60 ? text : undefined,
172
+ }
173
+ }
174
+
175
+ /**
176
+ * Table names as the two halves of the system spell them.
177
+ *
178
+ * `getForeignKeys()` reports whatever the database calls a table while
179
+ * `getConstraints()` is camel-keyed, and the graph endpoint passes both
180
+ * through untouched. Comparing them literally makes every foreign key on a
181
+ * `snake_case` schema invisible, which is the entire feature silently absent.
182
+ */
183
+ export function sameTable(a: string, b: string): boolean {
184
+ return a === b || normalize(a) === normalize(b)
185
+ }