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