@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,361 @@
1
+ /**
2
+ * Actions over a selection: set one column across it, delete it, or add rows.
3
+ *
4
+ * **Every count that reaches a dialog comes from a `dryRun`.** The server
5
+ * executes the statements inside a transaction and rolls back, so the number is
6
+ * what would actually happen — not the number of checkboxes, which is the same
7
+ * number only when nothing has changed underneath and no key has gone missing.
8
+ * `confirm.ts` turns that count into the right amount of ceremony.
9
+ */
10
+
11
+ import { coerceValue, omittableOnInsert } from '../shared/coerce'
12
+ import { type ApiError, bulkEdit, deleteRows, insertRows } from './api'
13
+ import { confirmDanger, notify, offerUndo } from './confirm'
14
+ import { append, box, button, each, el, select } from './dom'
15
+ import type { UndoStack } from './edit-session'
16
+ import { createEditor } from './editors'
17
+ import { columnMeta, type SchemaColumn, type SchemaTable } from './meta'
18
+
19
+ export interface BulkContext {
20
+ table: SchemaTable
21
+ columns: SchemaColumn[]
22
+ editable: boolean
23
+ selectedKeys: () => Record<string, unknown>[]
24
+ selectedRows: () => Record<string, unknown>[]
25
+ reload: () => Promise<void>
26
+ undo: UndoStack
27
+ openImport: () => void
28
+ }
29
+
30
+ export interface BulkToolbar {
31
+ node: HTMLElement
32
+ setCount: (count: number) => void
33
+ }
34
+
35
+ export function bulkToolbar(ctx: BulkContext): BulkToolbar {
36
+ const node = box('toolbar')
37
+ const count = el('span', { class: 'note' })
38
+
39
+ if (!ctx.editable) {
40
+ node.appendChild(
41
+ el('span', { class: 'note', text: ctx.table.reason ?? 'read-only' }),
42
+ )
43
+ return { node, setCount: () => {} }
44
+ }
45
+
46
+ const setColumn = button('Set column…', () => void openSetColumn(ctx), {
47
+ class: 'btn',
48
+ })
49
+ const remove = button('Delete', () => void runDelete(ctx), {
50
+ class: 'btn danger-btn',
51
+ })
52
+ const add = button('Add rows…', () => openInsert(ctx), { class: 'btn' })
53
+ const importer = button('Import CSV…', ctx.openImport, { class: 'btn' })
54
+
55
+ append(node, [add, importer, setColumn, remove, count])
56
+
57
+ const setCount = (selected: number) => {
58
+ count.textContent = selected ? `${selected} selected` : ''
59
+ setColumn.disabled = selected === 0
60
+ remove.disabled = selected === 0
61
+ }
62
+ setCount(0)
63
+ return { node, setCount }
64
+ }
65
+
66
+ // ------------------------------------------------------------ set one column
67
+
68
+ /**
69
+ * One column, one value, across the selection.
70
+ *
71
+ * The value goes through the same editor the grid uses, so an enum is a select
72
+ * and a nullable column can be set to NULL — the operation people actually
73
+ * reach for and the one a plain text prompt cannot express.
74
+ */
75
+ async function openSetColumn(ctx: BulkContext): Promise<void> {
76
+ const editable = ctx.columns.filter(column => !column.autoIncrement)
77
+ const first = editable[0]
78
+ if (!first) return
79
+
80
+ const dialog = el('dialog', { class: 'danger' })
81
+ dialog.appendChild(el('h3', { text: 'Set a column across the selection' }))
82
+
83
+ let column = first
84
+ let value: unknown = null
85
+ const slot = box('panel-field')
86
+
87
+ const paint = () => {
88
+ slot.replaceChildren()
89
+ const editor = createEditor(column, null, {
90
+ onInput: next => {
91
+ value = next
92
+ },
93
+ onKey: () => {},
94
+ })
95
+ slot.appendChild(editor.node)
96
+ value = editor.read()
97
+ }
98
+
99
+ const picker = select(
100
+ editable.map(c => ({ value: c.name, label: `${c.name} — ${c.type}` })),
101
+ column.name,
102
+ name => {
103
+ column = editable.find(c => c.name === name) ?? column
104
+ paint()
105
+ },
106
+ )
107
+ paint()
108
+
109
+ const apply = button(
110
+ 'Apply',
111
+ () => {
112
+ dialog.close()
113
+ void applySetColumn(ctx, column, value)
114
+ },
115
+ { class: 'btn primary' },
116
+ )
117
+ const cancel = button('Cancel', () => dialog.close(), { class: 'btn' })
118
+ const bar = box('row-bar', cancel, apply)
119
+ append(dialog, [picker, slot, bar])
120
+
121
+ document.body.appendChild(dialog)
122
+ dialog.addEventListener('close', () => dialog.remove())
123
+ dialog.showModal()
124
+ }
125
+
126
+ async function applySetColumn(
127
+ ctx: BulkContext,
128
+ column: SchemaColumn,
129
+ value: unknown,
130
+ ): Promise<void> {
131
+ const check = coerceValue(value, columnMeta(column))
132
+ if (!check.ok) {
133
+ notify(`${column.name}: ${check.message}`, 'error')
134
+ return
135
+ }
136
+
137
+ const edits = ctx
138
+ .selectedKeys()
139
+ .map(key => ({ key, set: { [column.name]: check.value } }))
140
+ if (!edits.length) return
141
+
142
+ const dry = await dryRun(() =>
143
+ bulkEdit({ table: ctx.table.name, edits, dryRun: true }),
144
+ )
145
+ if (!dry) return
146
+
147
+ const decision = await confirmDanger({
148
+ verb: `set ${column.name}`,
149
+ count: dry.changed,
150
+ table: ctx.table.name,
151
+ detail: `to ${describe(check.value)}`,
152
+ })
153
+ if (decision.refusal) return notify(decision.refusal, 'error')
154
+ if (!decision.ok) return
155
+
156
+ await run(async () => {
157
+ const result = await bulkEdit({ table: ctx.table.name, edits })
158
+ notify(`${result.changed} rows updated`)
159
+ await ctx.reload()
160
+ })
161
+ }
162
+
163
+ function describe(value: unknown): string {
164
+ if (value === null) return 'NULL'
165
+ if (typeof value === 'string') return JSON.stringify(value)
166
+ return String(value)
167
+ }
168
+
169
+ // -------------------------------------------------------------------- delete
170
+
171
+ async function runDelete(ctx: BulkContext): Promise<void> {
172
+ const keys = ctx.selectedKeys()
173
+ const rows = ctx.selectedRows()
174
+ if (!keys.length) return
175
+
176
+ const dry = await dryRun(() =>
177
+ deleteRows({ table: ctx.table.name, keys, dryRun: true }),
178
+ )
179
+ if (!dry) return
180
+
181
+ const decision = await confirmDanger({
182
+ verb: 'delete',
183
+ count: dry.deleted,
184
+ table: ctx.table.name,
185
+ detail: dry.conflicts.length
186
+ ? `${dry.conflicts.length} of the selected rows no longer match`
187
+ : undefined,
188
+ })
189
+ if (decision.refusal) return notify(decision.refusal, 'error')
190
+ if (!decision.ok) return
191
+
192
+ await run(async () => {
193
+ const result = await deleteRows({ table: ctx.table.name, keys })
194
+ await ctx.reload()
195
+ offerDeleteUndo(ctx, rows, result.deleted)
196
+ })
197
+ }
198
+
199
+ /**
200
+ * A ten-second offer to put the row back.
201
+ *
202
+ * Only for the immediate tier — anything above one row went through a dialog,
203
+ * and re-inserting a hundred rows from the browser's memory is a second bulk
204
+ * write dressed up as a safety net. The pre-image is already in hand because
205
+ * the grid holds the rows it rendered.
206
+ */
207
+ function offerDeleteUndo(
208
+ ctx: BulkContext,
209
+ rows: Record<string, unknown>[],
210
+ deleted: number,
211
+ ): void {
212
+ if (deleted !== 1 || rows.length !== 1) {
213
+ notify(`${deleted} rows deleted`)
214
+ return
215
+ }
216
+ const row = rows[0]!
217
+ const restore = async () => {
218
+ await run(async () => {
219
+ await insertRows({ table: ctx.table.name, rows: [row] })
220
+ await ctx.reload()
221
+ })
222
+ }
223
+ ctx.undo.push({ label: `delete from ${ctx.table.name}`, undo: restore })
224
+ offerUndo('1 row deleted', restore)
225
+ }
226
+
227
+ // -------------------------------------------------------------------- insert
228
+
229
+ /**
230
+ * Add rows by hand.
231
+ *
232
+ * A column is sent only when the user gave it a value or when the database
233
+ * cannot supply one — `omittableOnInsert` is the same predicate the importer's
234
+ * blocking check uses. Sending every column would defeat every default in the
235
+ * schema and would make an auto-increment key impossible to leave alone.
236
+ */
237
+ function openInsert(ctx: BulkContext): void {
238
+ const columns = ctx.columns.filter(column => !column.autoIncrement)
239
+ const dialog = el('dialog', { class: 'danger wide' })
240
+ dialog.appendChild(el('h3', { text: `Add rows to ${ctx.table.name}` }))
241
+
242
+ const list = box('insert-rows')
243
+ const drafts: Map<string, unknown>[] = []
244
+
245
+ const addRow = () => {
246
+ const draft = new Map<string, unknown>()
247
+ drafts.push(draft)
248
+ const row = box('insert-row')
249
+ row.appendChild(el('span', { class: 'note', text: `row ${drafts.length}` }))
250
+ each(row, columns, column => insertField(column, draft))
251
+ list.appendChild(row)
252
+ }
253
+ addRow()
254
+
255
+ const message = el('p', { class: 'row-error' })
256
+ const submit = button(
257
+ 'Insert',
258
+ () => {
259
+ const built = buildInsertRows(columns, drafts)
260
+ if (built.errors.length) {
261
+ message.textContent = built.errors.join('; ')
262
+ return
263
+ }
264
+ dialog.close()
265
+ void run(async () => {
266
+ const result = await insertRows({
267
+ table: ctx.table.name,
268
+ rows: built.rows,
269
+ })
270
+ notify(`${result.inserted} rows inserted`)
271
+ await ctx.reload()
272
+ })
273
+ },
274
+ { class: 'btn primary' },
275
+ )
276
+
277
+ const bar = box(
278
+ 'row-bar',
279
+ button('+ row', addRow, { class: 'btn' }),
280
+ button('Cancel', () => dialog.close(), { class: 'btn' }),
281
+ submit,
282
+ )
283
+ append(dialog, [list, message, bar])
284
+ document.body.appendChild(dialog)
285
+ dialog.addEventListener('close', () => dialog.remove())
286
+ dialog.showModal()
287
+ }
288
+
289
+ function insertField(column: SchemaColumn, draft: Map<string, unknown>): Node {
290
+ const wrap = box('panel-field')
291
+ wrap.appendChild(el('label', { class: 'panel-label', text: column.name }))
292
+ const editor = createEditor(column, null, {
293
+ onInput: value => draft.set(column.name, value),
294
+ onKey: () => {},
295
+ })
296
+ wrap.appendChild(editor.node)
297
+ return wrap
298
+ }
299
+
300
+ export interface BuiltRows {
301
+ rows: Record<string, unknown>[]
302
+ errors: string[]
303
+ }
304
+
305
+ /**
306
+ * Drafts to records, with the same coercion the server will apply.
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.
311
+ */
312
+ export function buildInsertRows(
313
+ columns: readonly SchemaColumn[],
314
+ drafts: readonly Map<string, unknown>[],
315
+ ): BuiltRows {
316
+ const rows: Record<string, unknown>[] = []
317
+ const errors: string[] = []
318
+
319
+ drafts.forEach((draft, index) => {
320
+ const record: Record<string, unknown> = {}
321
+ for (const column of columns) {
322
+ const meta = columnMeta(column)
323
+ const given = draft.has(column.name)
324
+ if (!given && omittableOnInsert(meta)) continue
325
+ const result = coerceValue(draft.get(column.name) ?? null, meta)
326
+ if (!result.ok) {
327
+ errors.push(`row ${index + 1} · ${column.name}: ${result.message}`)
328
+ continue
329
+ }
330
+ record[column.name] = result.value
331
+ }
332
+ rows.push(record)
333
+ })
334
+
335
+ return { rows, errors }
336
+ }
337
+
338
+ // ------------------------------------------------------------------ plumbing
339
+
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> {
342
+ try {
343
+ return await call()
344
+ } catch (error) {
345
+ notify(messageOf(error), 'error')
346
+ return null
347
+ }
348
+ }
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
+ }
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Keyboard navigation, as a reducer.
3
+ *
4
+ * **Pure, and the reason it exists apart from the grid.** A key handler that
5
+ * reads the DOM to decide what to do can only be tested by building a DOM;
6
+ * this one takes a plain `{key, shiftKey}` and a plain cursor and answers with
7
+ * an action, so `cell.test.ts` pins the whole interaction model — including the
8
+ * two rules that are easy to regress, *Enter commits and moves down* and
9
+ * *blur is not in this table at all* — without happy-dom.
10
+ *
11
+ * **Blur is deliberately absent.** The dashboard saves on blur, so a stray
12
+ * click into another cell is a write, and a three-column edit is three
13
+ * statements with two moments in between where the row is half-updated. Here
14
+ * blur only stages into the row's dirty buffer; the only things that commit are
15
+ * Enter, Tab, and the row's own Save button.
16
+ */
17
+
18
+ /** The parts of a `KeyboardEvent` this reducer reads. Nothing DOM-shaped. */
19
+ export interface KeyEvent {
20
+ key: string
21
+ shiftKey?: boolean
22
+ ctrlKey?: boolean
23
+ metaKey?: boolean
24
+ altKey?: boolean
25
+ }
26
+
27
+ export interface CellState {
28
+ row: number
29
+ col: number
30
+ /** Rows on the current page. */
31
+ rows: number
32
+ /** Columns in the grid. */
33
+ cols: number
34
+ /** Is an editor open on the focused cell? */
35
+ editing: boolean
36
+ /** May this table be edited at all? Decided once, before first paint. */
37
+ editable: boolean
38
+ }
39
+
40
+ export type CellAction =
41
+ | { type: 'none' }
42
+ | { type: 'move'; row: number; col: number }
43
+ | { type: 'edit' }
44
+ /** Write the editor's value into the row buffer, then move. */
45
+ | { type: 'commit'; move: 'down' | 'right' | 'left' | 'none' }
46
+ /** Throw the editor's value away and restore the cell. */
47
+ | { type: 'cancel' }
48
+ /** Stage SQL NULL — distinct from staging the empty string. */
49
+ | { type: 'null' }
50
+
51
+ const NONE: CellAction = { type: 'none' }
52
+
53
+ type Rule = (event: KeyEvent, state: CellState) => CellAction
54
+
55
+ function clamp(value: number, max: number): number {
56
+ if (value < 0) return 0
57
+ return value > max ? max : value
58
+ }
59
+
60
+ /** A move, or nothing when the cursor is already against that edge. */
61
+ function moveBy(
62
+ state: CellState,
63
+ rowStep: number,
64
+ colStep: number,
65
+ ): CellAction {
66
+ const row = clamp(state.row + rowStep, state.rows - 1)
67
+ const col = clamp(state.col + colStep, state.cols - 1)
68
+ if (row === state.row && col === state.col) return NONE
69
+ return { type: 'move', row, col }
70
+ }
71
+
72
+ function moveTo(state: CellState, row: number, col: number): CellAction {
73
+ const next = {
74
+ row: clamp(row, state.rows - 1),
75
+ col: clamp(col, state.cols - 1),
76
+ }
77
+ if (next.row === state.row && next.col === state.col) return NONE
78
+ return { type: 'move', ...next }
79
+ }
80
+
81
+ /** Only when the table is editable — a read-only grid still navigates. */
82
+ function whenEditable(action: CellAction): Rule {
83
+ return (_event, state) => (state.editable ? action : NONE)
84
+ }
85
+
86
+ /**
87
+ * While an editor is open. Four keys, and everything else belongs to the input
88
+ * — a `default` that swallowed keys here would make the editor unable to
89
+ * receive a literal `a`.
90
+ */
91
+ const EDITING: Record<string, Rule> = {
92
+ Enter: () => ({ type: 'commit', move: 'down' }),
93
+ Tab: event => ({ type: 'commit', move: event.shiftKey ? 'left' : 'right' }),
94
+ Escape: () => ({ type: 'cancel' }),
95
+ }
96
+
97
+ /** While a cell is merely selected. */
98
+ const BROWSING: Record<string, Rule> = {
99
+ ArrowUp: (_e, state) => moveBy(state, -1, 0),
100
+ ArrowDown: (_e, state) => moveBy(state, 1, 0),
101
+ ArrowLeft: (_e, state) => moveBy(state, 0, -1),
102
+ ArrowRight: (_e, state) => moveBy(state, 0, 1),
103
+ Tab: (event, state) => moveBy(state, 0, event.shiftKey ? -1 : 1),
104
+ Home: (event, state) => moveTo(state, event.ctrlKey ? 0 : state.row, 0),
105
+ End: (event, state) =>
106
+ moveTo(state, event.ctrlKey ? state.rows - 1 : state.row, state.cols - 1),
107
+ PageUp: (_e, state) => moveBy(state, -10, 0),
108
+ PageDown: (_e, state) => moveBy(state, 10, 0),
109
+ Enter: whenEditable({ type: 'edit' }),
110
+ F2: whenEditable({ type: 'edit' }),
111
+ // Delete stages NULL rather than clearing to `''`. They are different
112
+ // values, the server enforces the difference, and a grid that offers only
113
+ // one of them cannot express the other.
114
+ Delete: whenEditable({ type: 'null' }),
115
+ }
116
+
117
+ /**
118
+ * One key, one action.
119
+ *
120
+ * An empty page answers `none` for everything: a cursor over zero rows has no
121
+ * valid position, and returning a move to `-1` would be a subscript the caller
122
+ * has to guard at every call site instead of here once.
123
+ */
124
+ export function keyOnCell(event: KeyEvent, state: CellState): CellAction {
125
+ if (state.rows <= 0 || state.cols <= 0) return NONE
126
+ const rule = (state.editing ? EDITING : BROWSING)[event.key]
127
+ return rule ? rule(event, state) : NONE
128
+ }
129
+
130
+ /** Where a `commit` leaves the cursor. Same clamping as a bare move. */
131
+ export function afterCommit(
132
+ state: CellState,
133
+ move: 'down' | 'right' | 'left' | 'none',
134
+ ): CellAction {
135
+ if (move === 'down') return moveBy(state, 1, 0)
136
+ if (move === 'right') return moveBy(state, 0, 1)
137
+ if (move === 'left') return moveBy(state, 0, -1)
138
+ return NONE
139
+ }
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Friction proportionate to blast radius, in the spirit of `db:sync`'s DANGER
3
+ * ZONE.
4
+ *
5
+ * `frictionFor` is **pure and tested**; the dialog below is the DOM half. The
6
+ * split matters because the ladder is policy — the thresholds are the answer to
7
+ * "how bad is this if it was a mis-click" — and policy that lives inside a
8
+ * click handler cannot be asserted.
9
+ *
10
+ * **The count fed to this must come from a `dryRun`, never from the page.** A
11
+ * selection of eight checkboxes on a filtered page can delete eight rows or,
12
+ * if the caller built the keys from something wider, rather more; the server's
13
+ * own preview is the only number that is the number.
14
+ */
15
+
16
+ import { el, on } from './dom'
17
+
18
+ export type Friction = 'immediate' | 'confirm' | 'typed' | 'refuse'
19
+
20
+ /**
21
+ * How much ceremony `count` rows deserve.
22
+ *
23
+ * - **≤ 1** — immediate, with an undo. One row is recoverable and a dialog per
24
+ * row makes the tool unusable for the thing it is for.
25
+ * - **2–100** — a dialog naming the count and what changes.
26
+ * - **101–10 000** — the same, plus typing the table name. At this size the
27
+ * user is doing something deliberate and should have to prove it.
28
+ * - **> 10 000** — refused. Not a dialog: there is no phrasing of "are you
29
+ * sure" that makes a ten-thousand-row unreviewed write a good idea, and the
30
+ * honest answer is to narrow it with a filter.
31
+ */
32
+ export function frictionFor(count: number): Friction {
33
+ if (!Number.isFinite(count) || count <= 1) return 'immediate'
34
+ if (count <= 100) return 'confirm'
35
+ if (count <= 10_000) return 'typed'
36
+ return 'refuse'
37
+ }
38
+
39
+ export interface DangerRequest {
40
+ /** What is about to happen, in the user's words: `delete`, `set status`. */
41
+ verb: string
42
+ /** The exact count, from a dry run. */
43
+ count: number
44
+ table: string
45
+ /** A second line: which column, which filter, whatever narrows it. */
46
+ detail?: string
47
+ }
48
+
49
+ export interface DangerOutcome {
50
+ ok: boolean
51
+ /** Present when refused by the ladder rather than by the user. */
52
+ refusal?: string
53
+ }
54
+
55
+ /**
56
+ * Ask, at the level `frictionFor` decided.
57
+ *
58
+ * Returns rather than throws, and returns `{ok: false}` for a dismissal and for
59
+ * a refusal alike — the caller does one check. The refusal carries its reason
60
+ * so the message names the ceiling instead of failing silently.
61
+ */
62
+ export async function confirmDanger(
63
+ request: DangerRequest,
64
+ ): Promise<DangerOutcome> {
65
+ const level = frictionFor(request.count)
66
+ if (level === 'immediate') return { ok: true }
67
+ if (level === 'refuse') {
68
+ return {
69
+ ok: false,
70
+ refusal:
71
+ `${request.count} rows is past the ${(10_000).toLocaleString()} row ` +
72
+ 'ceiling for one action — narrow it with a filter',
73
+ }
74
+ }
75
+ const ok = await openDialog(request, level === 'typed')
76
+ return { ok }
77
+ }
78
+
79
+ /**
80
+ * Ask, unconditionally.
81
+ *
82
+ * For the questions that are not about blast radius. Discarding one row of
83
+ * unsaved typing deserves a prompt even though `frictionFor(1)` is `immediate`
84
+ * — the ladder is about how much damage a mis-click does to the *database*,
85
+ * and this one is about work the user has done and the database has not seen.
86
+ */
87
+ export async function confirmChoice(request: DangerRequest): Promise<boolean> {
88
+ return await openDialog(request, false)
89
+ }
90
+
91
+ /**
92
+ * A modal `<dialog>`.
93
+ *
94
+ * The native element rather than a hand-rolled overlay: it traps focus, closes
95
+ * on Escape, and is inert to the page behind it without a single line of
96
+ * script. Resolved through a `close` listener so a dismissal by any route —
97
+ * Escape, the backdrop, the button — lands in one place.
98
+ */
99
+ function openDialog(request: DangerRequest, typed: boolean): Promise<boolean> {
100
+ const dialog = el('dialog', { class: 'danger' })
101
+ const title = el('h3', {
102
+ text: `${request.verb} ${request.count.toLocaleString()} row${
103
+ request.count === 1 ? '' : 's'
104
+ }`,
105
+ })
106
+ const where = el('p', {
107
+ class: 'note',
108
+ text: `in ${request.table}${request.detail ? ` · ${request.detail}` : ''}`,
109
+ })
110
+ dialog.append(title, where)
111
+
112
+ const confirm = el('button', { class: 'btn danger-btn', text: request.verb })
113
+ confirm.type = 'button'
114
+
115
+ if (typed) {
116
+ const prompt = el('p', {
117
+ class: 'note',
118
+ text: `Type ${request.table} to confirm.`,
119
+ })
120
+ const input = el('input', { class: 'ed' })
121
+ input.type = 'text'
122
+ input.setAttribute('aria-label', 'table name')
123
+ confirm.disabled = true
124
+ input.addEventListener('input', () => {
125
+ confirm.disabled = input.value.trim() !== request.table
126
+ })
127
+ dialog.append(prompt, input)
128
+ }
129
+
130
+ const cancel = el('button', { class: 'btn', text: 'Cancel' })
131
+ cancel.type = 'button'
132
+ const bar = el('div', { class: 'row-bar' })
133
+ bar.append(cancel, confirm)
134
+ dialog.appendChild(bar)
135
+
136
+ let accepted = false
137
+ on(confirm, 'click', () => {
138
+ accepted = true
139
+ dialog.close()
140
+ })
141
+ on(cancel, 'click', () => dialog.close())
142
+
143
+ // `settle` is narrowed to `(value: boolean) => void` rather than used as the
144
+ // executor's own `resolve`, whose parameter is `boolean | PromiseLike<boolean>`
145
+ // — calling that reads as an unhandled promise to the floating-promise rule.
146
+ let settle: (value: boolean) => void = () => {}
147
+ const answered = new Promise<boolean>(resolve => {
148
+ settle = resolve
149
+ })
150
+ dialog.addEventListener('close', () => {
151
+ dialog.remove()
152
+ settle(accepted)
153
+ })
154
+
155
+ document.body.appendChild(dialog)
156
+ dialog.showModal()
157
+ return answered
158
+ }
159
+
160
+ /**
161
+ * A short-lived bar offering to put one thing back.
162
+ *
163
+ * Ten seconds, and the timer is the undo window rather than a toast animation:
164
+ * the action already happened. Returns a disposer so a second action can
165
+ * retire the first offer instead of stacking two.
166
+ */
167
+ export function offerUndo(
168
+ message: string,
169
+ undo: () => Promise<void>,
170
+ seconds = 10,
171
+ ): () => void {
172
+ const bar = el('div', { class: 'undo-bar', attrs: { role: 'status' } })
173
+ bar.appendChild(el('span', { text: message }))
174
+
175
+ const action = el('button', { class: 'btn', text: 'Undo' })
176
+ action.type = 'button'
177
+ const remove = () => {
178
+ clearTimeout(timer)
179
+ bar.remove()
180
+ }
181
+ on(action, 'click', () => {
182
+ remove()
183
+ void undo()
184
+ })
185
+ bar.appendChild(action)
186
+
187
+ const timer = setTimeout(remove, seconds * 1000)
188
+ document.body.appendChild(bar)
189
+ return remove
190
+ }
191
+
192
+ /** A message that is not a question. Used for a refusal and for a failure. */
193
+ export function notify(message: string, kind: 'info' | 'error' = 'info'): void {
194
+ const bar = el('div', {
195
+ class: `undo-bar ${kind}`,
196
+ text: message,
197
+ attrs: { role: kind === 'error' ? 'alert' : 'status' },
198
+ })
199
+ document.body.appendChild(bar)
200
+ setTimeout(() => bar.remove(), 6000)
201
+ }