@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,218 @@
1
+ /**
2
+ * What the explorer is looking at, and how that fits in a URL.
3
+ *
4
+ * **Pure.** `encodeView`/`decodeView` are a round trip over a string and are
5
+ * tested as one. The store below is a plain mutable object with no DOM in it;
6
+ * the hash is written by the entry module, which is the only place that knows
7
+ * about `location`.
8
+ *
9
+ * The view lives in the **hash** rather than the query string on purpose. The
10
+ * explorer is reached with `?db-key=…` on a first visit, the client scrubs that
11
+ * parameter out of the URL immediately, and a view state sharing the query
12
+ * string would be scrubbed along with it — or worse, would have to be
13
+ * reconstructed by an edit to the same `URLSearchParams` the credential was
14
+ * just removed from. A hash also never reaches the server, which is the right
15
+ * place for "which row am I on".
16
+ */
17
+
18
+ import type { Filter } from '../shared/filters'
19
+ import { isFilterOp } from '../shared/filters'
20
+ import type { SchemaGraph, SchemaReport, SchemaTable } from './meta'
21
+
22
+ export const PAGE_SIZE = 50
23
+
24
+ /** The three views a table has. One level of nesting, and only one. */
25
+ export type TableView = 'data' | 'structure' | 'relations'
26
+
27
+ const TABLE_VIEWS: readonly TableView[] = ['data', 'structure', 'relations']
28
+
29
+ export interface ViewState {
30
+ table: string
31
+ /** Which of Data / Structure / Relations is showing. */
32
+ view: TableView
33
+ page: number
34
+ sortBy: string | null
35
+ sortOrder: 'ASC' | 'DESC'
36
+ /**
37
+ * Column, operator and operand — several, each removable.
38
+ *
39
+ * A **list** rather than a record, because the builder lets a filter exist
40
+ * before it has a column or a value and a record cannot hold a half-built
41
+ * one. `toWire` is what collapses it to the record `getData` takes.
42
+ */
43
+ filters: Filter[]
44
+ }
45
+
46
+ export function defaultView(table = ''): ViewState {
47
+ return {
48
+ table,
49
+ view: 'data',
50
+ page: 1,
51
+ sortBy: null,
52
+ sortOrder: 'ASC',
53
+ filters: [],
54
+ }
55
+ }
56
+
57
+ /**
58
+ * `t` table, `v` view, `p` page, `s` sort column, `o` order, `f` filters.
59
+ *
60
+ * Short keys because several of these are encoded per open tab and the whole
61
+ * thing has to stay something a person can paste into chat. Defaults are
62
+ * omitted rather than written, so the common case is `t=parcels`.
63
+ *
64
+ * There is no `r` any more. It carried a row identity because a filter could
65
+ * not name a row — `id=1` matched `11` under a substring `LIKE` — and with `eq`
66
+ * available a foreign-key jump is just a filter. An old link with `r=` decodes
67
+ * to the same page it always did, minus the highlight.
68
+ */
69
+ export function encodeView(view: ViewState): string {
70
+ const params = new URLSearchParams()
71
+ if (view.table) params.set('t', view.table)
72
+ if (view.view !== 'data') params.set('v', view.view)
73
+ if (view.page > 1) params.set('p', String(view.page))
74
+ if (view.sortBy) {
75
+ params.set('s', view.sortBy)
76
+ if (view.sortOrder === 'DESC') params.set('o', 'DESC')
77
+ }
78
+ if (view.filters.length) params.set('f', JSON.stringify(view.filters))
79
+ return params.toString()
80
+ }
81
+
82
+ export function decodeView(hash: string): ViewState {
83
+ const params = new URLSearchParams(hash.replace(/^#/, ''))
84
+ const view = defaultView(params.get('t') ?? '')
85
+ view.view = readTableView(params.get('v'))
86
+ const page = Number.parseInt(params.get('p') ?? '1', 10)
87
+ view.page = Number.isFinite(page) && page > 0 ? page : 1
88
+ view.sortBy = params.get('s')
89
+ view.sortOrder = params.get('o') === 'DESC' ? 'DESC' : 'ASC'
90
+ view.filters = readFilters(params.get('f'))
91
+ return view
92
+ }
93
+
94
+ function readTableView(raw: string | null): TableView {
95
+ return TABLE_VIEWS.find(name => name === raw) ?? 'data'
96
+ }
97
+
98
+ /**
99
+ * Filters out of a URL, or none.
100
+ *
101
+ * Nothing here is trusted: the hash is user-supplied, and a filter carrying an
102
+ * operator the ORM does not know would be *dropped* server-side and quietly
103
+ * widen the result. Anything that does not typecheck as a filter is discarded
104
+ * here, before it can be sent.
105
+ */
106
+ function readFilters(raw: string | null): Filter[] {
107
+ if (!raw) return []
108
+ let parsed: unknown
109
+ try {
110
+ parsed = JSON.parse(raw)
111
+ } catch {
112
+ // A truncated or hand-edited link. Falling back to "no filters" renders
113
+ // the table the link asked for, which beats an error page.
114
+ return []
115
+ }
116
+ if (!Array.isArray(parsed)) return []
117
+ return parsed.filter(isFilter)
118
+ }
119
+
120
+ function isFilter(value: unknown): value is Filter {
121
+ if (typeof value !== 'object' || value === null) return false
122
+ const entry = value as Record<string, unknown>
123
+ return (
124
+ typeof entry.column === 'string' &&
125
+ entry.column !== '' &&
126
+ isFilterOp(entry.op) &&
127
+ (entry.value === undefined || typeof entry.value === 'string')
128
+ )
129
+ }
130
+
131
+ /** Everything the running client holds. One object, mutated in place. */
132
+ export interface AppState {
133
+ report: SchemaReport | null
134
+ graph: SchemaGraph | null
135
+ /** Views to return to, pushed by a foreign-key jump. */
136
+ trail: ViewState[]
137
+ /** Whether the sidebar lists the framework's own tables. Off by default. */
138
+ showSystem: boolean
139
+ }
140
+
141
+ export function createState(): AppState {
142
+ return { report: null, graph: null, trail: [], showSystem: false }
143
+ }
144
+
145
+ export function tableOf(
146
+ state: AppState,
147
+ name: string,
148
+ ): SchemaTable | undefined {
149
+ return state.report?.tables.find(table => table.name === name)
150
+ }
151
+
152
+ /**
153
+ * The ORM's own bookkeeping, which is not the user's data.
154
+ *
155
+ * `__bakery_schema` is the sync ledger (`orm/src/sync/ledger.ts`), and
156
+ * `__bakery` is the reserved prefix convention 10 names. Matching the prefix
157
+ * rather than the one literal name means a second ledger table would be hidden
158
+ * on the day it lands rather than on the day someone notices.
159
+ *
160
+ * Matched against the **raw** database name, which is what the schema report
161
+ * carries: `introspect()` builds from `getSchema()`, and that speaks raw names.
162
+ * (`getConstraints()` camel-cases, so the same table is `bakerySchema` there —
163
+ * that spelling never reaches this client, and matching it here would risk
164
+ * hiding a user table that happens to be called `bakerySchema`.)
165
+ */
166
+ const SYSTEM_PREFIX = '__bakery'
167
+
168
+ export function isSystemTable(name: string): boolean {
169
+ return name.startsWith(SYSTEM_PREFIX)
170
+ }
171
+
172
+ /**
173
+ * The tables the sidebar shows.
174
+ *
175
+ * Hidden behind a checkbox rather than removed outright, because every real
176
+ * client offers the toggle: a ledger row is occasionally exactly what someone
177
+ * needs to look at, and a table that cannot be reached at all is a support
178
+ * question.
179
+ */
180
+ export function visibleTables(
181
+ tables: readonly SchemaTable[],
182
+ showSystem: boolean,
183
+ ): SchemaTable[] {
184
+ return showSystem ? [...tables] : tables.filter(t => !isSystemTable(t.name))
185
+ }
186
+
187
+ /** How many are being hidden, for the checkbox's own label. */
188
+ export function systemTableCount(tables: readonly SchemaTable[]): number {
189
+ return tables.filter(table => isSystemTable(table.name)).length
190
+ }
191
+
192
+ /**
193
+ * Whether this table may be edited, decided **once, before first paint**.
194
+ *
195
+ * Two independent reasons it may not be, and the client is told both by
196
+ * `/api/_db/schema` rather than discovering them at save time: the session may
197
+ * be `read`, or the table may have no identity. Drawing an editable grid and
198
+ * then refusing the save is the failure this answers early.
199
+ */
200
+ export function editableTable(
201
+ state: AppState,
202
+ table: SchemaTable | undefined,
203
+ ): boolean {
204
+ if (!table) return false
205
+ return state.report?.access === 'write' && table.writable
206
+ }
207
+
208
+ /** Why not, in the words the server used. Never invented here. */
209
+ export function readOnlyReason(
210
+ state: AppState,
211
+ table: SchemaTable | undefined,
212
+ ): string {
213
+ if (!table) return 'no table selected'
214
+ if (state.report?.access !== 'write') {
215
+ return table.reason ?? 'this session may read but not write'
216
+ }
217
+ return table.reason ?? table.identity.reason ?? 'this table is read-only'
218
+ }
@@ -0,0 +1,130 @@
1
+ /**
2
+ * The bar along the bottom: what is on screen, what it cost, what may be done
3
+ * to it.
4
+ *
5
+ * Every database client has one, and it earns its space by answering three
6
+ * questions that otherwise need a round trip through the developer tools: how
7
+ * many rows there really are, how long the server took, and whether this
8
+ * session can write at all. The last one is the reason it exists here — the
9
+ * access level used to be a line of sidebar text that scrolled away.
10
+ *
11
+ * `statusParts` is **pure** and is where the wording lives, so the segments are
12
+ * asserted rather than read off a screenshot.
13
+ */
14
+
15
+ import { box, el } from './dom'
16
+
17
+ export interface StatusFacts {
18
+ table: string | null
19
+ totalRows?: number
20
+ page: number
21
+ totalPages?: number
22
+ /** Milliseconds the server reported for the last table-data call. */
23
+ ms: number | null
24
+ access: 'read' | 'write' | false
25
+ dirtyRows: number
26
+ /** Filters currently applied, so a surprising row count has an explanation. */
27
+ filterCount: number
28
+ }
29
+
30
+ /**
31
+ * The segments, in order, already worded.
32
+ *
33
+ * Absent facts are **omitted rather than rendered as a placeholder** — a
34
+ * `page 1 / ?` teaches nobody anything, and `getData` genuinely does not always
35
+ * return a total. An empty array is the honest answer for "nothing is open".
36
+ */
37
+ export function statusParts(facts: StatusFacts): string[] {
38
+ if (!facts.table) return []
39
+ const parts: string[] = []
40
+
41
+ if (facts.totalRows !== undefined) {
42
+ parts.push(`${formatCount(facts.totalRows)} row${plural(facts.totalRows)}`)
43
+ }
44
+ parts.push(
45
+ facts.totalPages !== undefined
46
+ ? `page ${facts.page} / ${facts.totalPages}`
47
+ : `page ${facts.page}`,
48
+ )
49
+ if (facts.filterCount > 0) {
50
+ parts.push(`${facts.filterCount} filter${plural(facts.filterCount)}`)
51
+ }
52
+ if (facts.ms !== null) parts.push(`${formatMs(facts.ms)} ms`)
53
+ parts.push(accessLabel(facts.access))
54
+ if (facts.dirtyRows > 0) {
55
+ parts.push(`${facts.dirtyRows} unsaved row${plural(facts.dirtyRows)}`)
56
+ }
57
+ return parts
58
+ }
59
+
60
+ function plural(count: number): string {
61
+ return count === 1 ? '' : 's'
62
+ }
63
+
64
+ /** Thousands separated, because a six-figure row count is unreadable without. */
65
+ function formatCount(count: number): string {
66
+ return count.toLocaleString('en-US')
67
+ }
68
+
69
+ /**
70
+ * Sub-millisecond timings keep one decimal.
71
+ *
72
+ * `getElapsed` returns a float, and a fast query rounding to `0 ms` reads as
73
+ * "not measured" rather than "fast" — which is the opposite of what it means.
74
+ */
75
+ function formatMs(ms: number): string {
76
+ return ms < 10 ? ms.toFixed(1) : String(Math.round(ms))
77
+ }
78
+
79
+ function accessLabel(access: 'read' | 'write' | false): string {
80
+ if (access === 'write') return 'read · write'
81
+ if (access === 'read') return 'read-only'
82
+ return 'no access'
83
+ }
84
+
85
+ export class StatusBar {
86
+ readonly node: HTMLElement
87
+ private readonly line: HTMLElement
88
+ /**
89
+ * The last facts painted.
90
+ *
91
+ * Kept so `bumpDirty` can repaint one segment without the caller having to
92
+ * reconstruct the row count, the page and the timing — which it cannot,
93
+ * since a staged edit does not re-fetch and the alternative would be showing
94
+ * a stale count or none.
95
+ */
96
+ private facts: StatusFacts = {
97
+ table: null,
98
+ page: 1,
99
+ ms: null,
100
+ access: false,
101
+ dirtyRows: 0,
102
+ filterCount: 0,
103
+ }
104
+
105
+ constructor() {
106
+ this.line = el('span', { class: 'status-parts' })
107
+ this.node = box('statusbar', this.line)
108
+ this.node.setAttribute('role', 'status')
109
+ }
110
+
111
+ paint(facts: StatusFacts): void {
112
+ this.facts = facts
113
+ this.render()
114
+ }
115
+
116
+ /** A staged or saved edit. Everything else on the bar is unchanged. */
117
+ bumpDirty(dirtyRows: number): void {
118
+ if (this.facts.dirtyRows === dirtyRows) return
119
+ this.facts = { ...this.facts, dirtyRows }
120
+ this.render()
121
+ }
122
+
123
+ private render(): void {
124
+ this.line.textContent = statusParts(this.facts).join(' · ')
125
+ // The dirty count is the one segment worth colouring, and it is always
126
+ // last — so the class goes on the bar rather than on a span nobody can see
127
+ // when the bar is empty.
128
+ this.node.classList.toggle('dirty', this.facts.dirtyRows > 0)
129
+ }
130
+ }
@@ -0,0 +1,231 @@
1
+ /**
2
+ * The Structure view: what the table *is*, as opposed to what is in it.
3
+ *
4
+ * Every serious client — DBeaver, DataGrip, Devart — splits a table into Data
5
+ * and its structure, and keeps them apart. This one was the single biggest
6
+ * omission: `GET /api/_db/schema` has always returned the type, nullability,
7
+ * default, enum values, auto-increment flag and resolved row identity of every
8
+ * column, and the client used all of it *only* to pick which editor widget to
9
+ * open. None of it was ever on screen. The reason a table is read-only was the
10
+ * worst of it — it existed, in the server's own words, and appeared nowhere
11
+ * except a tooltip on a padlock.
12
+ *
13
+ * `structureRows` is **pure** and is the whole derivation, so what each column
14
+ * is said to be is asserted rather than eyeballed.
15
+ */
16
+
17
+ import { box, el } from './dom'
18
+ import type { SchemaColumn, SchemaIndex, SchemaTable } from './meta'
19
+
20
+ /** One row of the columns table, already worded. */
21
+ export interface StructureRow {
22
+ name: string
23
+ type: string
24
+ nullable: string
25
+ /** `AUTO`, a literal `DEFAULT`, or empty — never a guess. */
26
+ default: string
27
+ /** `PK`, `unique`, or empty. */
28
+ key: string
29
+ /** The enum members, comma-joined, or empty. */
30
+ values: string
31
+ /** Whether this column is part of the row identity. */
32
+ identity: boolean
33
+ }
34
+
35
+ /**
36
+ * A column as the Structure table shows it.
37
+ *
38
+ * Two decisions worth naming, because both are places a plausible shortcut is
39
+ * wrong:
40
+ *
41
+ * - **`hasDefault` and `autoIncrement` are different facts**, and an
42
+ * auto-increment column is reported as such rather than as "has a default".
43
+ * They differ in the one place it matters — `omittableOnInsert` — and
44
+ * conflating them on screen would teach the reader the wrong model.
45
+ * - **`notnull` is not the negation of `nullable` here.** The server sends
46
+ * both, from two different introspection calls (`getConstraints()` when it
47
+ * says anything, `getSchema()`'s flag otherwise), and `nullable` is the
48
+ * richer one. It is the one the editors obey, so it is the one shown.
49
+ */
50
+ export function structureRow(
51
+ column: SchemaColumn,
52
+ identityCols: readonly string[],
53
+ indexes: readonly SchemaIndex[] = [],
54
+ ): StructureRow {
55
+ return {
56
+ name: column.name,
57
+ // The length is appended only when the database's own type string does not
58
+ // already carry it. `getSchema()` reports `VARCHAR(255)` on every dialect
59
+ // that has sized text, so appending unconditionally rendered
60
+ // `VARCHAR(255)(255)`. SQLite's `TEXT` with a declared length is the case
61
+ // that still needs the suffix.
62
+ type:
63
+ column.length && !column.type.includes('(')
64
+ ? `${column.type}(${column.length})`
65
+ : column.type,
66
+ nullable: column.nullable ? 'NULL' : 'NOT NULL',
67
+ default: defaultOf(column),
68
+ key: keyOf(column, indexes),
69
+ values: column.enum?.length ? column.enum.join(', ') : '',
70
+ identity: identityCols.includes(column.name),
71
+ }
72
+ }
73
+
74
+ function defaultOf(column: SchemaColumn): string {
75
+ if (column.autoIncrement) return 'AUTO'
76
+ return column.hasDefault ? 'DEFAULT' : ''
77
+ }
78
+
79
+ /**
80
+ * Primary key first, then unique-index membership.
81
+ *
82
+ * Only `unique` indexes are reported as a key. A plain index makes a column
83
+ * faster to look up and does nothing to make a row nameable, and calling both
84
+ * "key" is how someone concludes a table is addressable when it is not.
85
+ */
86
+ function keyOf(column: SchemaColumn, indexes: readonly SchemaIndex[]): string {
87
+ if (column.pk) return 'PK'
88
+ const unique = indexes.some(
89
+ index => index.type === 'unique' && index.cols.includes(column.name),
90
+ )
91
+ return unique ? 'unique' : ''
92
+ }
93
+
94
+ export function structureRows(table: SchemaTable): StructureRow[] {
95
+ return table.columns.map(column =>
96
+ structureRow(column, table.identity.cols, table.indexes ?? []),
97
+ )
98
+ }
99
+
100
+ // --------------------------------------------------------------------- render
101
+
102
+ const HEADINGS = [
103
+ 'column',
104
+ 'type',
105
+ 'null',
106
+ 'default',
107
+ 'key',
108
+ 'values',
109
+ 'identity',
110
+ ] as const
111
+
112
+ export interface StructureContext {
113
+ table: SchemaTable
114
+ editable: boolean
115
+ /** Why not, in the server's words. Shown prominently when not editable. */
116
+ reason: string
117
+ }
118
+
119
+ export function renderStructure(ctx: StructureContext): HTMLElement {
120
+ const node = box('structure')
121
+ const parts: (HTMLElement | null)[] = [
122
+ identitySection(ctx),
123
+ columnsSection(ctx.table),
124
+ indexesSection(ctx.table),
125
+ ]
126
+ for (const part of parts) if (part) node.appendChild(part)
127
+ return node
128
+ }
129
+
130
+ /**
131
+ * How a row of this table is named — and, when it cannot be, why.
132
+ *
133
+ * First rather than last, and a banner rather than a footnote. "This table is
134
+ * read-only" with no reason is the message that generates support questions;
135
+ * the reason has always existed and is the server's own sentence.
136
+ */
137
+ function identitySection(ctx: StructureContext): HTMLElement {
138
+ const section = box('structure-section')
139
+ section.appendChild(el('h3', { text: 'Row identity' }))
140
+
141
+ const identity = ctx.table.identity
142
+ if (identity.mode === 'none') {
143
+ section.appendChild(
144
+ el('p', {
145
+ class: 'banner warn',
146
+ text: `no row of this table can be named — ${ctx.reason}`,
147
+ }),
148
+ )
149
+ return section
150
+ }
151
+
152
+ section.appendChild(
153
+ el('p', {
154
+ class: 'note',
155
+ text:
156
+ `a row is named by its ${identity.mode === 'pk' ? 'primary key' : 'unique index'}: ` +
157
+ identity.cols.join(', '),
158
+ }),
159
+ )
160
+ if (!ctx.editable) {
161
+ section.appendChild(el('p', { class: 'banner warn', text: ctx.reason }))
162
+ }
163
+ return section
164
+ }
165
+
166
+ function columnsSection(table: SchemaTable): HTMLElement {
167
+ const section = box('structure-section')
168
+ 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))
177
+ return section
178
+ }
179
+
180
+ function columnRow(row: StructureRow): HTMLElement {
181
+ const tr = el('tr')
182
+ const cells = [
183
+ row.name,
184
+ row.type,
185
+ row.nullable,
186
+ row.default,
187
+ row.key,
188
+ row.values,
189
+ row.identity ? '✓' : '',
190
+ ]
191
+ for (const text of cells) tr.appendChild(el('td', { class: 'cell', text }))
192
+ return tr
193
+ }
194
+
195
+ /**
196
+ * Indexes, or an explicit statement that there are none.
197
+ *
198
+ * An empty section beats an absent one: "this table has no indexes" is an
199
+ * answer, and a missing heading reads as a client that failed to load them.
200
+ */
201
+ function indexesSection(table: SchemaTable): HTMLElement {
202
+ const section = box('structure-section')
203
+ const indexes = table.indexes ?? []
204
+ section.appendChild(el('h3', { text: `Indexes (${indexes.length})` }))
205
+
206
+ if (!indexes.length) {
207
+ section.appendChild(
208
+ el('p', { class: 'note', text: 'no indexes are declared on this table' }),
209
+ )
210
+ return section
211
+ }
212
+
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))
222
+ return section
223
+ }
224
+
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
231
+ }