@bakery-framework/plugin-db-explorer 2.0.0-alpha.6 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bakery-framework/plugin-db-explorer",
3
- "version": "2.0.0-alpha.6",
3
+ "version": "2.0.0-alpha.7",
4
4
  "description": "Bakery database explorer plugin — browse and edit rows — no raw SQL, no DDL.",
5
5
  "keywords": [
6
6
  "bakery",
@@ -33,8 +33,8 @@
33
33
  "!src/tests"
34
34
  ],
35
35
  "dependencies": {
36
- "@bakery-framework/core": "^2.0.0-alpha.6",
37
- "@bakery-framework/orm": "^2.0.0-alpha.6"
36
+ "@bakery-framework/core": "^2.0.0-alpha.7",
37
+ "@bakery-framework/orm": "^2.0.0-alpha.7"
38
38
  },
39
39
  "engines": {
40
40
  "bun": ">=1.3.14"
package/src/client/api.ts CHANGED
@@ -30,6 +30,24 @@ export class ApiError extends Error {
30
30
  }
31
31
  }
32
32
 
33
+ /**
34
+ * What to show a user when a call threw.
35
+ *
36
+ * Here rather than in `dom.ts` because the thing it knows about is `ApiError`,
37
+ * whose `message` is the server's own sentence from the envelope — the reason
38
+ * a failed write reads as "row was modified by someone else" instead of
39
+ * "Request failed (409)". `String(error)` is the fallback for the two throws
40
+ * that are not ours: an abort and a network failure.
41
+ *
42
+ * There used to be three of these — one exported from `bulk.ts`, a private
43
+ * near-copy in `save.ts`, and a third inlined at the `notify` call in
44
+ * `csv-commit.ts` — so improving the wording in one left the other two alone.
45
+ */
46
+ export function messageOf(error: unknown): string {
47
+ const api = error as Partial<ApiError>
48
+ return api?.message ?? String(error)
49
+ }
50
+
33
51
  const KEY_STORAGE = '__db_key'
34
52
  const KEY_PARAM = 'db-key'
35
53
 
@@ -79,7 +97,7 @@ interface Envelope {
79
97
  * available for "why is this slow": a filter that cannot use an index shows up
80
98
  * here immediately.
81
99
  */
82
- export interface Timed<T> {
100
+ interface Timed<T> {
83
101
  data: T
84
102
  ms: number
85
103
  }
@@ -101,7 +119,7 @@ async function unwrap<T>(res: Response): Promise<T> {
101
119
  return (await unwrapEnvelope<T>(res)).data
102
120
  }
103
121
 
104
- export async function apiGet<T>(
122
+ async function apiGet<T>(
105
123
  path: string,
106
124
  params?: Record<string, string>,
107
125
  signal?: AbortSignal,
@@ -114,7 +132,7 @@ export async function apiGet<T>(
114
132
  return await unwrap<T>(res)
115
133
  }
116
134
 
117
- export async function apiSend<T>(
135
+ async function apiSend<T>(
118
136
  method: 'POST' | 'PATCH' | 'DELETE',
119
137
  path: string,
120
138
  body: unknown,
@@ -192,7 +210,7 @@ export async function lookupRefs(
192
210
  return data.rows ?? []
193
211
  }
194
212
 
195
- export interface UpdateResult {
213
+ interface UpdateResult {
196
214
  changed: number
197
215
  row: Record<string, unknown> | null
198
216
  }
@@ -207,7 +225,7 @@ export async function patchRow(payload: {
207
225
  return await apiSend<UpdateResult>('PATCH', 'row', payload)
208
226
  }
209
227
 
210
- export interface BulkResult {
228
+ interface BulkResult {
211
229
  changed: number
212
230
  conflicts: { index: number; key: Record<string, unknown>; reason: string }[]
213
231
  }
@@ -220,7 +238,7 @@ export async function bulkEdit(payload: {
220
238
  return await apiSend<BulkResult>('POST', 'rows/bulk', payload)
221
239
  }
222
240
 
223
- export interface DeleteResult {
241
+ interface DeleteResult {
224
242
  deleted: number
225
243
  conflicts: { index: number; key: Record<string, unknown>; reason: string }[]
226
244
  }
@@ -233,7 +251,7 @@ export async function deleteRows(payload: {
233
251
  return await apiSend<DeleteResult>('DELETE', 'rows', payload)
234
252
  }
235
253
 
236
- export interface InsertResult {
254
+ interface InsertResult {
237
255
  inserted: number
238
256
  rows?: Record<string, unknown>[]
239
257
  }
@@ -9,7 +9,7 @@
9
9
  */
10
10
 
11
11
  import { coerceValue, omittableOnInsert } from '../shared/coerce'
12
- import { type ApiError, bulkEdit, deleteRows, insertRows } from './api'
12
+ import { bulkEdit, deleteRows, insertRows, messageOf } from './api'
13
13
  import { confirmDanger, notify, offerUndo } from './confirm'
14
14
  import { append, box, button, each, el, select } from './dom'
15
15
  import type { UndoStack } from './edit-session'
@@ -139,7 +139,7 @@ async function applySetColumn(
139
139
  .map(key => ({ key, set: { [column.name]: check.value } }))
140
140
  if (!edits.length) return
141
141
 
142
- const dry = await dryRun(() =>
142
+ const dry = await run(() =>
143
143
  bulkEdit({ table: ctx.table.name, edits, dryRun: true }),
144
144
  )
145
145
  if (!dry) return
@@ -173,7 +173,7 @@ async function runDelete(ctx: BulkContext): Promise<void> {
173
173
  const rows = ctx.selectedRows()
174
174
  if (!keys.length) return
175
175
 
176
- const dry = await dryRun(() =>
176
+ const dry = await run(() =>
177
177
  deleteRows({ table: ctx.table.name, keys, dryRun: true }),
178
178
  )
179
179
  if (!dry) return
@@ -297,7 +297,7 @@ function insertField(column: SchemaColumn, draft: Map<string, unknown>): Node {
297
297
  return wrap
298
298
  }
299
299
 
300
- export interface BuiltRows {
300
+ interface BuiltRows {
301
301
  rows: Record<string, unknown>[]
302
302
  errors: string[]
303
303
  }
@@ -305,11 +305,12 @@ export interface BuiltRows {
305
305
  /**
306
306
  * Drafts to records, with the same coercion the server will apply.
307
307
  *
308
- * Exported because it is the decision in this file that is worth pinning: an
309
- * untouched omittable column is *absent* from the record, and an untouched
310
- * required one is an error named here rather than a database exception later.
308
+ * Named rather than inlined into the submit handler because it is the decision
309
+ * in this file worth stating: an untouched omittable column is *absent* from
310
+ * the record, and an untouched required one is an error named here rather than
311
+ * a database exception later.
311
312
  */
312
- export function buildInsertRows(
313
+ function buildInsertRows(
313
314
  columns: readonly SchemaColumn[],
314
315
  drafts: readonly Map<string, unknown>[],
315
316
  ): BuiltRows {
@@ -337,8 +338,16 @@ export function buildInsertRows(
337
338
 
338
339
  // ------------------------------------------------------------------ plumbing
339
340
 
340
- /** A dry run, with its failure reported rather than thrown at the click handler. */
341
- async function dryRun<T>(call: () => Promise<T>): Promise<T | null> {
341
+ /**
342
+ * Run a call, reporting its failure rather than throwing at the click handler.
343
+ *
344
+ * A rejected promise from an event listener is an unhandled rejection and a
345
+ * silent no-op on screen, which is the one outcome a destructive action must
346
+ * not have. `null` is the failure, so a dry run's caller checks the result and
347
+ * an action's caller ignores it — they were two identically-bodied functions
348
+ * until the second was noticed to be the first with the value discarded.
349
+ */
350
+ async function run<T>(call: () => Promise<T>): Promise<T | null> {
342
351
  try {
343
352
  return await call()
344
353
  } catch (error) {
@@ -346,16 +355,3 @@ async function dryRun<T>(call: () => Promise<T>): Promise<T | null> {
346
355
  return null
347
356
  }
348
357
  }
349
-
350
- async function run(action: () => Promise<void>): Promise<void> {
351
- try {
352
- await action()
353
- } catch (error) {
354
- notify(messageOf(error), 'error')
355
- }
356
- }
357
-
358
- export function messageOf(error: unknown): string {
359
- const api = error as Partial<ApiError>
360
- return api?.message ?? String(error)
361
- }
@@ -13,7 +13,7 @@
13
13
  * own preview is the only number that is the number.
14
14
  */
15
15
 
16
- import { el, on } from './dom'
16
+ import { button, el } from './dom'
17
17
 
18
18
  export type Friction = 'immediate' | 'confirm' | 'typed' | 'refuse'
19
19
 
@@ -109,8 +109,15 @@ function openDialog(request: DangerRequest, typed: boolean): Promise<boolean> {
109
109
  })
110
110
  dialog.append(title, where)
111
111
 
112
- const confirm = el('button', { class: 'btn danger-btn', text: request.verb })
113
- confirm.type = 'button'
112
+ let accepted = false
113
+ const confirm = button(
114
+ request.verb,
115
+ () => {
116
+ accepted = true
117
+ dialog.close()
118
+ },
119
+ { class: 'btn danger-btn' },
120
+ )
114
121
 
115
122
  if (typed) {
116
123
  const prompt = el('p', {
@@ -127,19 +134,11 @@ function openDialog(request: DangerRequest, typed: boolean): Promise<boolean> {
127
134
  dialog.append(prompt, input)
128
135
  }
129
136
 
130
- const cancel = el('button', { class: 'btn', text: 'Cancel' })
131
- cancel.type = 'button'
137
+ const cancel = button('Cancel', () => dialog.close(), { class: 'btn' })
132
138
  const bar = el('div', { class: 'row-bar' })
133
139
  bar.append(cancel, confirm)
134
140
  dialog.appendChild(bar)
135
141
 
136
- let accepted = false
137
- on(confirm, 'click', () => {
138
- accepted = true
139
- dialog.close()
140
- })
141
- on(cancel, 'click', () => dialog.close())
142
-
143
142
  // `settle` is narrowed to `(value: boolean) => void` rather than used as the
144
143
  // executor's own `resolve`, whose parameter is `boolean | PromiseLike<boolean>`
145
144
  // — calling that reads as an unhandled promise to the floating-promise rule.
@@ -172,17 +171,20 @@ export function offerUndo(
172
171
  const bar = el('div', { class: 'undo-bar', attrs: { role: 'status' } })
173
172
  bar.appendChild(el('span', { text: message }))
174
173
 
175
- const action = el('button', { class: 'btn', text: 'Undo' })
176
- action.type = 'button'
177
174
  const remove = () => {
178
175
  clearTimeout(timer)
179
176
  bar.remove()
180
177
  }
181
- on(action, 'click', () => {
182
- remove()
183
- void undo()
184
- })
185
- bar.appendChild(action)
178
+ bar.appendChild(
179
+ button(
180
+ 'Undo',
181
+ () => {
182
+ remove()
183
+ void undo()
184
+ },
185
+ { class: 'btn' },
186
+ ),
187
+ )
186
188
 
187
189
  const timer = setTimeout(remove, seconds * 1000)
188
190
  document.body.appendChild(bar)
@@ -8,7 +8,7 @@
8
8
  * are in.
9
9
  */
10
10
 
11
- import { type ImportResult, importRows } from './api'
11
+ import { type ImportResult, importRows, messageOf } from './api'
12
12
  import { notify } from './confirm'
13
13
  import {
14
14
  type BadRowPolicy,
@@ -171,7 +171,7 @@ async function sendBatch(
171
171
  try {
172
172
  return await importRows({ table: ctx.table.name, rows, onBadRow })
173
173
  } catch (error) {
174
- notify((error as Error)?.message ?? 'import failed', 'error')
174
+ notify(messageOf(error), 'error')
175
175
  return null
176
176
  }
177
177
  }
@@ -16,8 +16,8 @@ import {
16
16
  import { append, box, each, el, on, select } from './dom'
17
17
  import type { SchemaColumn } from './meta'
18
18
 
19
- export const SKIP_VALUE = ' skip'
20
- export const CONST_VALUE = ' const'
19
+ const SKIP_VALUE = ' skip'
20
+ const CONST_VALUE = ' const'
21
21
 
22
22
  export interface MapContext {
23
23
  columns: SchemaColumn[]
@@ -95,7 +95,7 @@ function summaryText(model: ImportModel): string {
95
95
  * itself contains the *new* delimiter survives the round trip. This is why the
96
96
  * wizard can offer a delimiter override at all without holding the file.
97
97
  */
98
- export function rebuildSource(model: ImportModel): string {
98
+ function rebuildSource(model: ImportModel): string {
99
99
  const quote = (field: string) =>
100
100
  /["\n\r]/.test(field) || field.includes(model.delimiter)
101
101
  ? `"${field.replace(/"/g, '""')}"`
@@ -175,7 +175,7 @@ function currentValue(assignment: Assignment): string {
175
175
  return assignment.kind === 'constant' ? CONST_VALUE : SKIP_VALUE
176
176
  }
177
177
 
178
- export function assignmentFor(value: string, previous: Assignment): Assignment {
178
+ function assignmentFor(value: string, previous: Assignment): Assignment {
179
179
  if (value === SKIP_VALUE) return { kind: 'skip' }
180
180
  if (value === CONST_VALUE) {
181
181
  return {
@@ -17,7 +17,7 @@ import {
17
17
  type RowPreview,
18
18
  setEmptyToNull,
19
19
  } from './csv-model'
20
- import { box, each, el, on } from './dom'
20
+ import { box, each, el, gridTable, on } from './dom'
21
21
  import type { SchemaColumn } from './meta'
22
22
 
23
23
  export function paintPreview(
@@ -34,11 +34,9 @@ export function paintPreview(
34
34
  return
35
35
  }
36
36
 
37
- const table = el('table', { class: 'grid' })
38
- const head = el('tr')
39
37
  const names = rows[0]!.cells.map(cell => cell.column)
40
- each(head, names, column => el('th', { text: column }))
41
- table.append(head, nullToggleRow(model, names, update))
38
+ const table = gridTable(names)
39
+ table.appendChild(nullToggleRow(model, names, update))
42
40
  each(table, rows, row => previewRow(row))
43
41
  node.appendChild(box('scroll', table))
44
42
  }
package/src/client/dom.ts CHANGED
@@ -85,6 +85,26 @@ export function each<T>(
85
85
  })
86
86
  }
87
87
 
88
+ /**
89
+ * A `<table class="grid">` with its heading row already in it.
90
+ *
91
+ * The two lines this replaces — build the table, loop `<th>` into a `<tr>` —
92
+ * had been written out three times, twice in `structure.ts` and once in
93
+ * `csv-preview.ts`. Rows are appended by the caller, because the three differ
94
+ * in what a row is: `structure.ts` has plain text cells, and `csv-preview.ts`
95
+ * puts a checkbox row directly under the headings.
96
+ */
97
+ export function gridTable(
98
+ headings: readonly string[],
99
+ cls = 'grid',
100
+ ): HTMLTableElement {
101
+ const table = el('table', { class: cls })
102
+ const head = el('tr')
103
+ each(head, headings, heading => el('th', { text: heading }))
104
+ table.appendChild(head)
105
+ return table
106
+ }
107
+
88
108
  export function clear(node: Node): void {
89
109
  ;(node as Element).replaceChildren()
90
110
  }
package/src/client/fk.ts CHANGED
@@ -64,7 +64,6 @@ class BoundedCache<V> {
64
64
  }
65
65
  }
66
66
 
67
-
68
67
  export interface FkTarget {
69
68
  /** Columns in *this* table that make up the key. */
70
69
  cols: string[]
@@ -98,6 +97,34 @@ export function fkForColumn(
98
97
  return null
99
98
  }
100
99
 
100
+ /**
101
+ * Every foreign-key column of one table, resolved once.
102
+ *
103
+ * `fkForColumn` is a linear scan of the whole graph, and the grid asked it per
104
+ * cell, per paint — a fifty-row page of twenty columns against forty foreign
105
+ * keys is forty thousand comparisons, each of which had `sameTable` allocate
106
+ * two lowercased strings, and `repaintRow`/`repaintCell` run it again. The scan
107
+ * does not depend on the row, so it happens once when the grid is built.
108
+ *
109
+ * First declaration wins, exactly as the scan's early `return` does, so a
110
+ * column named by two keys resolves to the same one either way.
111
+ */
112
+ export function fkMapFor(
113
+ graph: SchemaGraph | null,
114
+ table: string,
115
+ ): Map<string, FkTarget> {
116
+ const map = new Map<string, FkTarget>()
117
+ if (!graph) return map
118
+ for (const fk of Object.values(graph.foreignKeys)) {
119
+ if (!sameTable(fk.table, table)) continue
120
+ const target = toTarget(fk)
121
+ for (const column of fk.cols) {
122
+ if (!map.has(column)) map.set(column, target)
123
+ }
124
+ }
125
+ return map
126
+ }
127
+
101
128
  /** Every key pointing *at* this table. The row panel's "referenced by" list. */
102
129
  export function reverseFks(
103
130
  graph: SchemaGraph | null,
@@ -11,18 +11,20 @@
11
11
  */
12
12
 
13
13
  import { button } from './dom'
14
- import {
15
- type FkResolver,
16
- type FkTarget,
17
- fkForColumn,
18
- fkKeyOf,
19
- fkLabel,
20
- } from './fk'
14
+ import { type FkResolver, type FkTarget, fkKeyOf, fkLabel } from './fk'
21
15
  import { cellProps, type SchemaColumn, type SchemaGraph } from './meta'
22
16
 
23
17
  export interface CellPaintContext {
24
18
  table: string
25
19
  graph: SchemaGraph | null
20
+ /**
21
+ * Which columns of `table` are foreign keys, resolved once by `fkMapFor`.
22
+ *
23
+ * Built by the grid rather than looked up here: the answer depends on the
24
+ * table and not on the row, and asking per cell per paint made a page repaint
25
+ * quadratic in the size of the schema graph.
26
+ */
27
+ fks: ReadonlyMap<string, FkTarget>
26
28
  resolver: FkResolver
27
29
  onFollowFk: (target: FkTarget, key: Record<string, unknown>) => void
28
30
  }
@@ -47,18 +49,19 @@ export function paintCell(
47
49
  td.className = props.className
48
50
  if (staged) td.classList.add('staged')
49
51
  if (props.title) td.title = props.title
50
- td.appendChild(cellBody(ctx, row, column, value))
52
+ // `props.text` is passed down rather than recomputed: `cellBody` wants the
53
+ // same string, and `cellProps` was being run twice for every cell.
54
+ td.appendChild(cellBody(ctx, row, column, props.text))
51
55
  }
52
56
 
53
57
  function cellBody(
54
58
  ctx: CellPaintContext,
55
59
  row: Record<string, unknown>,
56
60
  column: SchemaColumn,
57
- value: unknown,
61
+ text: string,
58
62
  ): Node {
59
- const target = fkForColumn(ctx.graph, ctx.table, column.name)
63
+ const target = ctx.fks.get(column.name)
60
64
  const key = target ? fkKeyOf(target, row) : null
61
- const text = cellProps(value, column).text
62
65
  if (!target || !key) return document.createTextNode(text)
63
66
  return fkButton(ctx, target, key, text)
64
67
  }
@@ -27,7 +27,7 @@ import { box, each, el, on, setBusy } from './dom'
27
27
  import type { EditSession } from './edit-session'
28
28
  import { rowId } from './edit-session'
29
29
  import { createEditor, type EditorHandle } from './editors'
30
- import type { FkResolver, FkTarget } from './fk'
30
+ import { type FkResolver, type FkTarget, fkMapFor } from './fk'
31
31
  import { type CellPaintContext, paintCell } from './grid-body'
32
32
  import { buildHead } from './grid-header'
33
33
  import {
@@ -79,6 +79,8 @@ export class Grid {
79
79
  this.paintCtx = {
80
80
  table: ctx.table.name,
81
81
  graph: ctx.graph,
82
+ // Once per grid, not once per cell per paint — see `fkMapFor`.
83
+ fks: fkMapFor(ctx.graph, ctx.table.name),
82
84
  resolver: ctx.resolver,
83
85
  onFollowFk: ctx.onFollowFk,
84
86
  }
@@ -16,6 +16,7 @@
16
16
  */
17
17
 
18
18
  import type { ColumnKind, ColumnMeta } from '../shared/coerce'
19
+ import { normalize } from '../shared/plan'
19
20
 
20
21
  export interface SchemaColumn {
21
22
  name: string
@@ -180,9 +181,5 @@ export function cellProps(value: unknown, column: SchemaColumn): CellProps {
180
181
  * `snake_case` schema invisible, which is the entire feature silently absent.
181
182
  */
182
183
  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, '')
184
+ return a === b || normalize(a) === normalize(b)
188
185
  }
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  import type { ColumnKind } from '../shared/coerce'
11
- import { type ApiError, patchRow } from './api'
11
+ import { type ApiError, messageOf, patchRow } from './api'
12
12
  import { box, button, el } from './dom'
13
13
  import type { EditSession, RowPlan } from './edit-session'
14
14
  import type { SchemaTable } from './meta'
@@ -38,10 +38,6 @@ function kindLookup(
38
38
  return column => kinds.get(column)
39
39
  }
40
40
 
41
- function messageOf(error: unknown): string {
42
- return (error as Error)?.message ?? String(error)
43
- }
44
-
45
41
  /**
46
42
  * One row, one PATCH, carrying every changed column and its pre-image.
47
43
  *
@@ -14,7 +14,7 @@
14
14
  * is said to be is asserted rather than eyeballed.
15
15
  */
16
16
 
17
- import { box, el } from './dom'
17
+ import { append, box, each, el, gridTable } from './dom'
18
18
  import type { SchemaColumn, SchemaIndex, SchemaTable } from './meta'
19
19
 
20
20
  /** One row of the columns table, already worded. */
@@ -109,6 +109,29 @@ const HEADINGS = [
109
109
  'identity',
110
110
  ] as const
111
111
 
112
+ const INDEX_HEADINGS = ['index', 'type', 'columns'] as const
113
+
114
+ /**
115
+ * A scrolling table of plain text cells.
116
+ *
117
+ * Columns and indexes are the same table twice — headings, then a `<tr>` of
118
+ * `<td class="cell">` per row — so they are one builder and two `string[][]`.
119
+ */
120
+ function dataTable(
121
+ headings: readonly string[],
122
+ rows: readonly (readonly string[])[],
123
+ ): HTMLElement {
124
+ const grid = gridTable(headings, 'grid structure-grid')
125
+ each(grid, rows, cells => dataRow(cells))
126
+ return box('scroll', grid)
127
+ }
128
+
129
+ function dataRow(cells: readonly string[]): HTMLElement {
130
+ const tr = el('tr')
131
+ each(tr, cells, text => el('td', { class: 'cell', text }))
132
+ return tr
133
+ }
134
+
112
135
  export interface StructureContext {
113
136
  table: SchemaTable
114
137
  editable: boolean
@@ -118,12 +141,11 @@ export interface StructureContext {
118
141
 
119
142
  export function renderStructure(ctx: StructureContext): HTMLElement {
120
143
  const node = box('structure')
121
- const parts: (HTMLElement | null)[] = [
144
+ append(node, [
122
145
  identitySection(ctx),
123
146
  columnsSection(ctx.table),
124
147
  indexesSection(ctx.table),
125
- ]
126
- for (const part of parts) if (part) node.appendChild(part)
148
+ ])
127
149
  return node
128
150
  }
129
151
 
@@ -166,20 +188,15 @@ function identitySection(ctx: StructureContext): HTMLElement {
166
188
  function columnsSection(table: SchemaTable): HTMLElement {
167
189
  const section = box('structure-section')
168
190
  section.appendChild(el('h3', { text: `Columns (${table.columns.length})` }))
169
-
170
- const grid = el('table', { class: 'grid structure-grid' })
171
- const head = el('tr')
172
- for (const heading of HEADINGS) head.appendChild(el('th', { text: heading }))
173
- grid.appendChild(head)
174
- for (const row of structureRows(table)) grid.appendChild(columnRow(row))
175
-
176
- section.appendChild(box('scroll', grid))
191
+ section.appendChild(
192
+ dataTable(HEADINGS, structureRows(table).map(columnCells)),
193
+ )
177
194
  return section
178
195
  }
179
196
 
180
- function columnRow(row: StructureRow): HTMLElement {
181
- const tr = el('tr')
182
- const cells = [
197
+ /** A `StructureRow` in `HEADINGS` order. The only place the two are paired. */
198
+ function columnCells(row: StructureRow): string[] {
199
+ return [
183
200
  row.name,
184
201
  row.type,
185
202
  row.nullable,
@@ -188,8 +205,6 @@ function columnRow(row: StructureRow): HTMLElement {
188
205
  row.values,
189
206
  row.identity ? '✓' : '',
190
207
  ]
191
- for (const text of cells) tr.appendChild(el('td', { class: 'cell', text }))
192
- return tr
193
208
  }
194
209
 
195
210
  /**
@@ -210,22 +225,10 @@ function indexesSection(table: SchemaTable): HTMLElement {
210
225
  return section
211
226
  }
212
227
 
213
- const grid = el('table', { class: 'grid structure-grid' })
214
- const head = el('tr')
215
- for (const heading of ['index', 'type', 'columns']) {
216
- head.appendChild(el('th', { text: heading }))
217
- }
218
- grid.appendChild(head)
219
- for (const index of indexes) grid.appendChild(indexRow(index))
220
-
221
- section.appendChild(box('scroll', grid))
228
+ section.appendChild(dataTable(INDEX_HEADINGS, indexes.map(indexCells)))
222
229
  return section
223
230
  }
224
231
 
225
- function indexRow(index: SchemaIndex): HTMLElement {
226
- const tr = el('tr')
227
- for (const text of [index.name, index.type, index.cols.join(', ')]) {
228
- tr.appendChild(el('td', { class: 'cell', text }))
229
- }
230
- return tr
232
+ function indexCells(index: SchemaIndex): string[] {
233
+ return [index.name, index.type, index.cols.join(', ')]
231
234
  }
@@ -21,7 +21,7 @@
21
21
  */
22
22
 
23
23
  import type { ViewState } from './state'
24
- import { decodeView, defaultView, encodeView } from './state'
24
+ import { decodeView, encodeView } from './state'
25
25
 
26
26
  export interface Tab {
27
27
  view: ViewState
@@ -47,7 +47,7 @@ export function activeView(state: TabsState): ViewState | null {
47
47
  return activeTab(state)?.view ?? null
48
48
  }
49
49
 
50
- export function indexOfTable(state: TabsState, table: string): number {
50
+ function indexOfTable(state: TabsState, table: string): number {
51
51
  return state.tabs.findIndex(tab => tab.view.table === table)
52
52
  }
53
53
 
@@ -217,8 +217,3 @@ function readIndex(raw: string | null, length: number): number {
217
217
  const value = Number.parseInt(raw, 10)
218
218
  return Number.isFinite(value) && value >= 0 && value < length ? value : -1
219
219
  }
220
-
221
- /** A fresh tab for a table, as the sidebar and the relations view make one. */
222
- export function tabView(table: string): ViewState {
223
- return defaultView(table)
224
- }
package/src/client.ts CHANGED
@@ -21,7 +21,13 @@
21
21
  * editor is for.
22
22
  */
23
23
 
24
- import { adoptUrlKey, fetchGraph, fetchPage, fetchSchema } from './client/api'
24
+ import {
25
+ adoptUrlKey,
26
+ fetchGraph,
27
+ fetchPage,
28
+ fetchSchema,
29
+ messageOf,
30
+ } from './client/api'
25
31
  import { confirmChoice, notify } from './client/confirm'
26
32
  import { el } from './client/dom'
27
33
  import { EditSession, UndoStack } from './client/edit-session'
@@ -347,10 +353,6 @@ async function followFk(
347
353
 
348
354
  // ------------------------------------------------------------------ plumbing
349
355
 
350
- function messageOf(error: unknown): string {
351
- return (error as Error)?.message ?? String(error)
352
- }
353
-
354
356
  /**
355
357
  * The unload guard.
356
358
  *
Binary file
@@ -1,10 +1,10 @@
1
1
  /**
2
2
  * CSV import — the rows, not the file.
3
3
  *
4
- * The parse happens in `shared/csv.ts`, which the browser runs to show a
5
- * preview and the server runs on whatever it is sent. This endpoint takes rows
6
- * that are already records, so the mapping the user confirmed in the dialog is
7
- * what arrives, rather than a file the server re-guesses the columns of.
4
+ * The parse happens in the browser, in `shared/csv.ts`, and stays there. This
5
+ * endpoint takes rows that are already records, so the mapping the user
6
+ * confirmed in the dialog is what arrives, rather than a file the server
7
+ * re-guesses the columns of. No CSV text reaches the server at all.
8
8
  *
9
9
  * Two things separate it from `POST /api/_db/rows`, and both are about scale:
10
10
  * the bound is a spreadsheet's worth of rows rather than an edit's, and a bad
package/src/shared/csv.ts CHANGED
@@ -1,9 +1,13 @@
1
1
  /**
2
2
  * CSV, RFC 4180-ish, with the delimiter sniffed rather than assumed.
3
3
  *
4
- * **Pure** — see the note at the top of `coerce.ts`. The client parses the file
5
- * the user dropped and the server re-parses whatever it is sent, and they have
6
- * to agree, so there is one parser.
4
+ * **Pure** — see the note at the top of `coerce.ts` and, unlike the rest of
5
+ * `shared/`, **browser-only**. It was written when the server re-parsed the
6
+ * text it was sent; `endpoints/import.ts` takes records that are already mapped
7
+ * and coerced, so no CSV text ever reaches it. `client/csv-model.ts` is the
8
+ * only caller. The file stays here because `client/safety.test.ts` holds
9
+ * `shared/` to the same no-framework-imports rule as `client/`, and because a
10
+ * server-side importer would want exactly this parser back.
7
11
  *
8
12
  * ## Why not `parseCSVRows` from `orm/adapters/base.ts:1071`
9
13
  *
@@ -31,24 +35,10 @@
31
35
  */
32
36
 
33
37
  /** The delimiters `sniffDelimiter` will consider, in preference order. */
34
- export const CANDIDATE_DELIMITERS = [',', ';', '\t', '|'] as const
38
+ const CANDIDATE_DELIMITERS = [',', ';', '\t', '|'] as const
35
39
 
36
40
  export type Delimiter = (typeof CANDIDATE_DELIMITERS)[number]
37
41
 
38
- export interface CSVTable {
39
- headers: string[]
40
- rows: string[][]
41
- delimiter: string
42
- /**
43
- * Rows whose field count differs from the header count, as
44
- * `{row, fields}` — 1-based over the data rows, so it lines up with what a
45
- * spreadsheet shows. Ragged rows are still returned in `rows`, padded with
46
- * `''` or truncated; refusing the whole file for one short line is not the
47
- * importer's call to make.
48
- */
49
- ragged: { row: number; fields: number }[]
50
- }
51
-
52
42
  /** Strip a UTF-8 BOM. Excel writes one; nothing downstream expects it. */
53
43
  export function stripBOM(text: string): string {
54
44
  return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text
@@ -194,42 +184,3 @@ export function parseCSVRows(text: string, delimiter = ','): string[][] {
194
184
 
195
185
  return rows
196
186
  }
197
-
198
- /**
199
- * A whole file: headers, data rows squared off against them, and what the
200
- * delimiter turned out to be.
201
- */
202
- export function parseCSV(
203
- text: string,
204
- options: { delimiter?: string } = {},
205
- ): CSVTable {
206
- const delimiter = options.delimiter ?? sniffDelimiter(text)
207
- const all = parseCSVRows(text, delimiter)
208
- if (!all.length) return { headers: [], rows: [], delimiter, ragged: [] }
209
-
210
- // Headers are the one place trimming is right: a header is a name, and
211
- // `" id "` naming the column `id` is what every spreadsheet means by it.
212
- const headers = all[0]!.map(h => h.trim())
213
- const ragged: { row: number; fields: number }[] = []
214
- const rows = all.slice(1).map((fields, index) => {
215
- if (fields.length !== headers.length) {
216
- ragged.push({ row: index + 1, fields: fields.length })
217
- }
218
- const squared = fields.slice(0, headers.length)
219
- while (squared.length < headers.length) squared.push('')
220
- return squared
221
- })
222
-
223
- return { headers, rows, delimiter, ragged }
224
- }
225
-
226
- /** A parsed table as records, keyed by header. */
227
- export function csvRecords(table: CSVTable): Record<string, string>[] {
228
- return table.rows.map(fields => {
229
- const record: Record<string, string> = {}
230
- for (let i = 0; i < table.headers.length; i++) {
231
- record[table.headers[i]!] = fields[i] ?? ''
232
- }
233
- return record
234
- })
235
- }
@@ -54,7 +54,16 @@ export function autoMap(
54
54
  return mapping
55
55
  }
56
56
 
57
- function normalize(name: string): string {
57
+ /**
58
+ * A name with case and separators removed.
59
+ *
60
+ * Exported because `client/meta.ts` compares *table* names the same way and
61
+ * held a byte-identical private copy called `flatten`. Both are in the browser
62
+ * bundle, so it was one rule written twice: a change to what counts as "the
63
+ * same name" would have fixed CSV auto-mapping and left foreign-key visibility
64
+ * on the old one.
65
+ */
66
+ export function normalize(name: string): string {
58
67
  return name.toLowerCase().replace(/[\s_-]+/g, '')
59
68
  }
60
69