@bakery-framework/plugin-db-explorer 2.0.0-alpha.4 → 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 +42 -42
  41. package/src/policy.ts +45 -0
  42. package/src/preview.ts +53 -0
  43. package/src/setup.ts +63 -80
  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/authorize.ts +0 -82
  51. package/src/endpoints.ts +0 -48
@@ -0,0 +1,205 @@
1
+ /**
2
+ * The Relations view: what this table points at, and what points back.
3
+ *
4
+ * `GET /api/_db/graph` has always returned every declared foreign key. The grid
5
+ * used it to turn a cell into a link, which is the per-row question; nobody
6
+ * could see the *table's* shape without clicking through a row that happened to
7
+ * have the reference filled in. Both directions are listed here, and both are
8
+ * clickable, because "which tables reference this one" is the question you ask
9
+ * before deleting anything.
10
+ *
11
+ * `relationsFor` is **pure** and does the grouping; the render half only turns
12
+ * it into buttons.
13
+ */
14
+
15
+ import type { Filter } from '../shared/filters'
16
+ import { box, button, each, el } from './dom'
17
+ import { equalityFilters } from './filter-builder'
18
+ import { reverseFks } from './fk'
19
+ import type { ForeignKeyInfo, SchemaGraph } from './meta'
20
+ import { sameTable } from './meta'
21
+
22
+ export interface Relation {
23
+ /** The columns on the near side — this table's, for `outgoing`. */
24
+ cols: string[]
25
+ /** The table at the other end. */
26
+ table: string
27
+ /** The columns at the other end. */
28
+ refCols: string[]
29
+ name?: string
30
+ }
31
+
32
+ export interface Relations {
33
+ /** Foreign keys declared *on* this table. */
34
+ outgoing: Relation[]
35
+ /** Foreign keys on other tables that point *at* this one. */
36
+ incoming: Relation[]
37
+ }
38
+
39
+ /**
40
+ * Both directions, grouped.
41
+ *
42
+ * `sameTable` rather than `===` throughout, and that is not defensive
43
+ * programming: `getForeignKeys()` reports whatever the database calls a table
44
+ * while `getConstraints()` is camel-keyed, and the graph endpoint passes both
45
+ * through untouched. Comparing literally makes every foreign key on a
46
+ * `snake_case` schema invisible — the whole feature silently absent, which is
47
+ * exactly what `meta.ts`'s own note on `sameTable` records.
48
+ *
49
+ * A self-reference — a `manager_id` pointing at the same table's `id` — appears
50
+ * in **both** lists, which is correct: it is genuinely a key this table
51
+ * declares and genuinely a key that points here.
52
+ */
53
+ export function relationsFor(
54
+ graph: SchemaGraph | null,
55
+ table: string,
56
+ ): Relations {
57
+ if (!graph) return { outgoing: [], incoming: [] }
58
+
59
+ const outgoing = Object.values(graph.foreignKeys)
60
+ .filter(fk => sameTable(fk.table, table))
61
+ .map(toOutgoing)
62
+ const incoming = reverseFks(graph, table).map(toIncoming)
63
+ return { outgoing, incoming }
64
+ }
65
+
66
+ function toOutgoing(fk: ForeignKeyInfo): Relation {
67
+ return {
68
+ cols: fk.cols,
69
+ table: fk.refTable,
70
+ refCols: fk.refCols,
71
+ name: fk.name,
72
+ }
73
+ }
74
+
75
+ /**
76
+ * An incoming key, read from the far side.
77
+ *
78
+ * `cols` are the *referencing* table's columns and `refCols` are ours, which is
79
+ * the reverse of the outgoing shape and is what makes the two lists read the
80
+ * same way on screen: near columns first, far table second.
81
+ */
82
+ function toIncoming(fk: ForeignKeyInfo): Relation {
83
+ return {
84
+ cols: fk.cols,
85
+ table: fk.table,
86
+ refCols: fk.refCols,
87
+ name: fk.name,
88
+ }
89
+ }
90
+
91
+ // --------------------------------------------------------------------- render
92
+
93
+ export interface RelationsContext {
94
+ table: string
95
+ graph: SchemaGraph | null
96
+ /** Open a table in a tab, optionally filtered. */
97
+ onOpen: (table: string, filters: Filter[]) => void
98
+ }
99
+
100
+ export function renderRelations(ctx: RelationsContext): HTMLElement {
101
+ const relations = relationsFor(ctx.graph, ctx.table)
102
+ const node = box('relations')
103
+
104
+ node.appendChild(
105
+ section(
106
+ `References (${relations.outgoing.length})`,
107
+ 'columns of this table that point elsewhere',
108
+ relations.outgoing,
109
+ relation => outgoingRow(ctx, relation),
110
+ ),
111
+ )
112
+ node.appendChild(
113
+ section(
114
+ `Referenced by (${relations.incoming.length})`,
115
+ 'tables whose rows point at this one',
116
+ relations.incoming,
117
+ relation => incomingRow(ctx, relation),
118
+ ),
119
+ )
120
+ return node
121
+ }
122
+
123
+ function section(
124
+ title: string,
125
+ blurb: string,
126
+ relations: readonly Relation[],
127
+ make: (relation: Relation) => HTMLElement,
128
+ ): HTMLElement {
129
+ const node = box('structure-section')
130
+ node.appendChild(el('h3', { text: title }))
131
+ node.appendChild(el('p', { class: 'note', text: blurb }))
132
+ if (!relations.length) {
133
+ node.appendChild(el('p', { class: 'note', text: 'none declared' }))
134
+ return node
135
+ }
136
+ const list = box('relation-list')
137
+ each(list, relations, make)
138
+ node.appendChild(list)
139
+ return node
140
+ }
141
+
142
+ /**
143
+ * `this.col → that.col`, opening the referenced table unfiltered.
144
+ *
145
+ * Unfiltered on purpose: an outgoing key is a statement about the *schema*, not
146
+ * about a row. Which row it points at depends on which row you are looking at,
147
+ * and that link is the one in the grid cell.
148
+ */
149
+ function outgoingRow(ctx: RelationsContext, relation: Relation): HTMLElement {
150
+ const row = box('relation')
151
+ row.appendChild(
152
+ el('span', {
153
+ class: 'relation-cols',
154
+ text: `${relation.cols.join(', ')} →`,
155
+ }),
156
+ )
157
+ row.appendChild(
158
+ button(
159
+ `${relation.table}.${relation.refCols.join(', ')}`,
160
+ () => ctx.onOpen(relation.table, []),
161
+ { class: 'btn', title: `open ${relation.table}` },
162
+ ),
163
+ )
164
+ if (relation.name) {
165
+ row.appendChild(el('span', { class: 'note', text: relation.name }))
166
+ }
167
+ return row
168
+ }
169
+
170
+ function incomingRow(ctx: RelationsContext, relation: Relation): HTMLElement {
171
+ const row = box('relation')
172
+ row.appendChild(
173
+ button(
174
+ `${relation.table}.${relation.cols.join(', ')}`,
175
+ () => ctx.onOpen(relation.table, []),
176
+ { class: 'btn', title: `open ${relation.table}` },
177
+ ),
178
+ )
179
+ row.appendChild(
180
+ el('span', {
181
+ class: 'relation-cols',
182
+ text: `→ ${relation.refCols.join(', ')}`,
183
+ }),
184
+ )
185
+ return row
186
+ }
187
+
188
+ /**
189
+ * The filters that open the rows of `relation.table` pointing at one row.
190
+ *
191
+ * Exported for the row panel, which is where "referenced by *this row*" lives.
192
+ * `eq` per column, so the count and the page are exact — this used to be a
193
+ * substring `LIKE` and the panel had to label its counts "approximate".
194
+ */
195
+ export function filtersForIncoming(
196
+ relation: Relation,
197
+ row: Record<string, unknown>,
198
+ ): Filter[] {
199
+ const key: Record<string, unknown> = {}
200
+ relation.refCols.forEach((refCol, index) => {
201
+ const column = relation.cols[index]
202
+ if (column) key[column] = row[refCol]
203
+ })
204
+ return equalityFilters(key)
205
+ }
@@ -0,0 +1,209 @@
1
+ /**
2
+ * Saving one row, and the conflict that is the interesting half.
3
+ *
4
+ * Kept apart from the page because it is the part with a decision in it: a
5
+ * rejected write must never be a lost edit, and a 409 must not be answered with
6
+ * a retry button. "Try again" against a row that moved is precisely how the
7
+ * other person's edit disappears.
8
+ */
9
+
10
+ import type { ColumnKind } from '../shared/coerce'
11
+ import { type ApiError, patchRow } from './api'
12
+ import { box, button, el } from './dom'
13
+ import type { EditSession, RowPlan } from './edit-session'
14
+ import type { SchemaTable } from './meta'
15
+
16
+ /**
17
+ * The part of the grid a save needs. Structural, so `Grid` satisfies it
18
+ * without knowing this module exists.
19
+ */
20
+ export interface RowSurface {
21
+ indexOfRow: (id: string) => number
22
+ setRowMessage: (index: number, message: string) => void
23
+ setRowBusy: (index: number, busy: boolean) => void
24
+ }
25
+
26
+ export interface SaveDeps {
27
+ session: EditSession
28
+ /** Read late: the grid is replaced on every repaint. */
29
+ surface: () => RowSurface | null
30
+ reload: () => Promise<void>
31
+ onDirtyChange: () => void
32
+ }
33
+
34
+ function kindLookup(
35
+ table: SchemaTable,
36
+ ): (column: string) => ColumnKind | undefined {
37
+ const kinds = new Map(table.columns.map(c => [c.name, c.kind as ColumnKind]))
38
+ return column => kinds.get(column)
39
+ }
40
+
41
+ function messageOf(error: unknown): string {
42
+ return (error as Error)?.message ?? String(error)
43
+ }
44
+
45
+ /**
46
+ * One row, one PATCH, carrying every changed column and its pre-image.
47
+ *
48
+ * The pre-image goes as `expect`, so the statement is `identity ∧ expect` and a
49
+ * concurrent edit is a 409 rather than a silent overwrite. `force` is sent only
50
+ * when a changed column is `json` or `buffer` — kinds SQL cannot compare, so
51
+ * there is no predicate to guard them with.
52
+ */
53
+ export async function saveRow(
54
+ table: SchemaTable,
55
+ id: string,
56
+ deps: SaveDeps,
57
+ ): Promise<void> {
58
+ const surface = deps.surface()
59
+ const plan = deps.session.plan(id, table.identity.cols, kindLookup(table))
60
+ if (!plan || !surface) return
61
+
62
+ const index = surface.indexOfRow(id)
63
+ if (plan.missingIdentity.length) {
64
+ surface.setRowMessage(
65
+ index,
66
+ `this row is missing ${plan.missingIdentity.join(', ')}`,
67
+ )
68
+ return
69
+ }
70
+
71
+ surface.setRowBusy(index, true)
72
+ try {
73
+ await send(table, plan, plan.expect)
74
+ deps.session.drop(id)
75
+ deps.onDirtyChange()
76
+ await deps.reload()
77
+ } catch (error) {
78
+ surface.setRowBusy(index, false)
79
+ await reportFailure(table, id, index, error, deps)
80
+ }
81
+ }
82
+
83
+ function send(
84
+ table: SchemaTable,
85
+ plan: RowPlan,
86
+ expect: Record<string, unknown>,
87
+ ): Promise<unknown> {
88
+ return patchRow({
89
+ table: table.name,
90
+ key: plan.where,
91
+ set: plan.set,
92
+ expect,
93
+ force: plan.unguardable.length > 0,
94
+ })
95
+ }
96
+
97
+ /** The typed values survive. Anything else would make the buffer pointless. */
98
+ async function reportFailure(
99
+ table: SchemaTable,
100
+ id: string,
101
+ index: number,
102
+ error: unknown,
103
+ deps: SaveDeps,
104
+ ): Promise<void> {
105
+ const api = error as ApiError
106
+ const surface = deps.surface()
107
+ if (api?.status !== 409) {
108
+ surface?.setRowMessage(index, messageOf(error))
109
+ return
110
+ }
111
+ const theirs = (api.data as { row?: Record<string, unknown> } | undefined)
112
+ ?.row
113
+ surface?.setRowMessage(index, 'this row changed since it was read')
114
+ await offerResolution(table, id, index, theirs ?? null, deps)
115
+ }
116
+
117
+ /**
118
+ * Keep mine / Take theirs, with the server's copy of the row in hand.
119
+ *
120
+ * The 409 body carries the row as it now stands — that is why the endpoint
121
+ * attaches it — so this is a real choice rather than a prompt to guess.
122
+ */
123
+ function offerResolution(
124
+ table: SchemaTable,
125
+ id: string,
126
+ index: number,
127
+ theirs: Record<string, unknown> | null,
128
+ deps: SaveDeps,
129
+ ): Promise<void> {
130
+ let settle: () => void = () => {}
131
+ const done = new Promise<void>(resolve => {
132
+ settle = resolve
133
+ })
134
+
135
+ const dialog = el('dialog', { class: 'danger' })
136
+ dialog.appendChild(
137
+ el('h3', { text: 'This row changed while you were editing' }),
138
+ )
139
+ dialog.appendChild(
140
+ el('p', {
141
+ class: 'note',
142
+ text: theirs
143
+ ? 'Keep mine re-sends your columns against the row as it is now. ' +
144
+ 'Take theirs discards your edits.'
145
+ : 'The row is gone. Your edits cannot be applied to it.',
146
+ }),
147
+ )
148
+
149
+ const takeTheirs = button(
150
+ 'Take theirs',
151
+ () => {
152
+ deps.session.drop(id)
153
+ dialog.close()
154
+ void deps.reload().then(settle, settle)
155
+ },
156
+ { class: 'btn' },
157
+ )
158
+
159
+ const keepMine = button(
160
+ 'Keep mine',
161
+ () => {
162
+ dialog.close()
163
+ void rebase(table, id, index, theirs, deps).then(settle, settle)
164
+ },
165
+ { class: 'btn primary' },
166
+ )
167
+ keepMine.disabled = theirs === null
168
+
169
+ dialog.appendChild(box('row-bar', takeTheirs, keepMine))
170
+ document.body.appendChild(dialog)
171
+ dialog.addEventListener('close', () => dialog.remove())
172
+ dialog.showModal()
173
+ return done
174
+ }
175
+
176
+ /**
177
+ * Keep mine: the same `set`, with `expect` taken from *their* row.
178
+ *
179
+ * Not `expect: {}` — that is last-write-wins, which the endpoint refuses to
180
+ * default to for exactly this reason. Rebasing on the row that was just shown
181
+ * means a *third* edit arriving between the 409 and this retry is a 409 again,
182
+ * which is the correct answer rather than an inconvenience.
183
+ */
184
+ async function rebase(
185
+ table: SchemaTable,
186
+ id: string,
187
+ index: number,
188
+ theirs: Record<string, unknown> | null,
189
+ deps: SaveDeps,
190
+ ): Promise<void> {
191
+ const surface = deps.surface()
192
+ if (!theirs || !surface) return
193
+ const plan = deps.session.plan(id, table.identity.cols, kindLookup(table))
194
+ if (!plan) return
195
+
196
+ const expect: Record<string, unknown> = {}
197
+ for (const column of Object.keys(plan.expect)) expect[column] = theirs[column]
198
+
199
+ surface.setRowBusy(index, true)
200
+ try {
201
+ await send(table, plan, expect)
202
+ deps.session.drop(id)
203
+ deps.onDirtyChange()
204
+ await deps.reload()
205
+ } catch (error) {
206
+ surface.setRowBusy(index, false)
207
+ surface.setRowMessage(index, messageOf(error))
208
+ }
209
+ }
@@ -0,0 +1,110 @@
1
+ /**
2
+ * The table list, and the one checkbox that decides what is in it.
3
+ *
4
+ * Split out of `page.ts` when tabs arrived: the sidebar is the only thing left
5
+ * that survives a tab switch, so it repaints on a different schedule from
6
+ * everything else and had no business sharing a module with the grid's chrome.
7
+ *
8
+ * A **single click opens a preview tab** and a **double click makes it
9
+ * permanent** — the two are wired here and defined in `tabs.ts`.
10
+ */
11
+
12
+ import { append, box, button, each, el, on } from './dom'
13
+ import type { SchemaTable } from './meta'
14
+ import { systemTableCount, visibleTables } from './state'
15
+
16
+ export interface SidebarContext {
17
+ tables: readonly SchemaTable[]
18
+ showSystem: boolean
19
+ /** The table showing in the active tab, so it can be marked. */
20
+ activeTable: string | null
21
+ /** Single click: a replaceable preview tab. */
22
+ onPreview: (table: string) => void
23
+ /** Double click: a tab that stays. */
24
+ onOpen: (table: string) => void
25
+ onToggleSystem: (show: boolean) => void
26
+ }
27
+
28
+ export function renderSidebar(ctx: SidebarContext): HTMLElement {
29
+ const side = el('nav', { class: 'side' })
30
+ side.appendChild(el('h1', { class: 'brand', text: 'db explorer' }))
31
+
32
+ const shown = visibleTables(ctx.tables, ctx.showSystem)
33
+ const list = box('table-list')
34
+ each(list, shown, table => tableButton(ctx, table))
35
+ side.appendChild(list)
36
+
37
+ if (!shown.length) {
38
+ side.appendChild(el('p', { class: 'note', text: 'no tables' }))
39
+ }
40
+ const toggle = systemToggle(ctx)
41
+ if (toggle) side.appendChild(toggle)
42
+ return side
43
+ }
44
+
45
+ /**
46
+ * The system-tables checkbox, present only when there is something to reveal.
47
+ *
48
+ * `__bakery_schema` is the ORM's sync ledger and is not the user's data, so it
49
+ * is hidden by default — but every real client offers the toggle rather than
50
+ * hiding such tables outright, because a ledger row is occasionally exactly
51
+ * what someone needs to see. The count is in the label so the checkbox says
52
+ * what it would do before it is clicked.
53
+ */
54
+ function systemToggle(ctx: SidebarContext): HTMLElement | null {
55
+ const hidden = systemTableCount(ctx.tables)
56
+ if (!hidden) return null
57
+
58
+ const check = el('input', {
59
+ attrs: { 'aria-label': 'show system tables' },
60
+ })
61
+ check.type = 'checkbox'
62
+ check.checked = ctx.showSystem
63
+ on(check, 'change', () => ctx.onToggleSystem(check.checked))
64
+
65
+ const label = el('label', {
66
+ class: 'note system-toggle',
67
+ text: ` show system tables (${hidden})`,
68
+ title: "the framework's own bookkeeping — the ORM sync ledger",
69
+ })
70
+ label.prepend(check)
71
+ return label
72
+ }
73
+
74
+ function tableButton(ctx: SidebarContext, table: SchemaTable): HTMLElement {
75
+ const node = button(table.name, () => ctx.onPreview(table.name), {
76
+ class: 'table-btn',
77
+ })
78
+ // `dblclick` fires *after* its two `click`s, so the single click has already
79
+ // opened the preview tab and this only promotes it — which is exactly VS
80
+ // Code's behaviour and needs no suppression of the first click.
81
+ on(node, 'dblclick', () => ctx.onOpen(table.name))
82
+
83
+ if (table.name === ctx.activeTable) node.classList.add('active')
84
+ append(node, [badgeFor(table)])
85
+ return node
86
+ }
87
+
88
+ /**
89
+ * A padlock for read-only, a different mark for a view.
90
+ *
91
+ * On the *list*, so the state is visible before the table is opened rather than
92
+ * at the first double-click into a cell.
93
+ */
94
+ function badgeFor(table: SchemaTable): HTMLElement | null {
95
+ if (table.isView) {
96
+ return el('span', {
97
+ class: 'ro',
98
+ text: '⊞',
99
+ title: 'a view — it has no rows of its own to address',
100
+ })
101
+ }
102
+ if (!table.writable) {
103
+ return el('span', {
104
+ class: 'ro',
105
+ text: '🔒',
106
+ title: table.reason ?? 'read-only',
107
+ })
108
+ }
109
+ return null
110
+ }