@bakery-framework/plugin-db-explorer 2.0.0-alpha.5 → 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.
Files changed (51) hide show
  1. package/package.json +4 -4
  2. package/src/access.ts +188 -0
  3. package/src/client/api.ts +279 -0
  4. package/src/client/bulk.ts +357 -0
  5. package/src/client/cell.ts +139 -0
  6. package/src/client/confirm.ts +203 -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 +89 -0
  12. package/src/client/csv.ts +104 -0
  13. package/src/client/dom.ts +164 -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 +269 -0
  18. package/src/client/grid-body.ts +106 -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 +468 -0
  22. package/src/client/meta.ts +185 -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 +205 -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 +234 -0
  31. package/src/client/tabs.ts +219 -0
  32. package/src/client/tabstrip.ts +127 -0
  33. package/src/client.ts +376 -160
  34. package/src/endpoints/common.ts +122 -0
  35. package/src/endpoints/graph.ts +148 -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 +186 -0
  46. package/src/shared/filters.ts +200 -0
  47. package/src/shared/plan.ts +173 -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,357 @@
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 { bulkEdit, deleteRows, insertRows, messageOf } 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 run(() =>
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 run(() =>
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
+ 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
+ * 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.
312
+ */
313
+ function buildInsertRows(
314
+ columns: readonly SchemaColumn[],
315
+ drafts: readonly Map<string, unknown>[],
316
+ ): BuiltRows {
317
+ const rows: Record<string, unknown>[] = []
318
+ const errors: string[] = []
319
+
320
+ drafts.forEach((draft, index) => {
321
+ const record: Record<string, unknown> = {}
322
+ for (const column of columns) {
323
+ const meta = columnMeta(column)
324
+ const given = draft.has(column.name)
325
+ if (!given && omittableOnInsert(meta)) continue
326
+ const result = coerceValue(draft.get(column.name) ?? null, meta)
327
+ if (!result.ok) {
328
+ errors.push(`row ${index + 1} · ${column.name}: ${result.message}`)
329
+ continue
330
+ }
331
+ record[column.name] = result.value
332
+ }
333
+ rows.push(record)
334
+ })
335
+
336
+ return { rows, errors }
337
+ }
338
+
339
+ // ------------------------------------------------------------------ plumbing
340
+
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> {
351
+ try {
352
+ return await call()
353
+ } catch (error) {
354
+ notify(messageOf(error), 'error')
355
+ return null
356
+ }
357
+ }
@@ -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,203 @@
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 { button, el } 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
+ let accepted = false
113
+ const confirm = button(
114
+ request.verb,
115
+ () => {
116
+ accepted = true
117
+ dialog.close()
118
+ },
119
+ { class: 'btn danger-btn' },
120
+ )
121
+
122
+ if (typed) {
123
+ const prompt = el('p', {
124
+ class: 'note',
125
+ text: `Type ${request.table} to confirm.`,
126
+ })
127
+ const input = el('input', { class: 'ed' })
128
+ input.type = 'text'
129
+ input.setAttribute('aria-label', 'table name')
130
+ confirm.disabled = true
131
+ input.addEventListener('input', () => {
132
+ confirm.disabled = input.value.trim() !== request.table
133
+ })
134
+ dialog.append(prompt, input)
135
+ }
136
+
137
+ const cancel = button('Cancel', () => dialog.close(), { class: 'btn' })
138
+ const bar = el('div', { class: 'row-bar' })
139
+ bar.append(cancel, confirm)
140
+ dialog.appendChild(bar)
141
+
142
+ // `settle` is narrowed to `(value: boolean) => void` rather than used as the
143
+ // executor's own `resolve`, whose parameter is `boolean | PromiseLike<boolean>`
144
+ // — calling that reads as an unhandled promise to the floating-promise rule.
145
+ let settle: (value: boolean) => void = () => {}
146
+ const answered = new Promise<boolean>(resolve => {
147
+ settle = resolve
148
+ })
149
+ dialog.addEventListener('close', () => {
150
+ dialog.remove()
151
+ settle(accepted)
152
+ })
153
+
154
+ document.body.appendChild(dialog)
155
+ dialog.showModal()
156
+ return answered
157
+ }
158
+
159
+ /**
160
+ * A short-lived bar offering to put one thing back.
161
+ *
162
+ * Ten seconds, and the timer is the undo window rather than a toast animation:
163
+ * the action already happened. Returns a disposer so a second action can
164
+ * retire the first offer instead of stacking two.
165
+ */
166
+ export function offerUndo(
167
+ message: string,
168
+ undo: () => Promise<void>,
169
+ seconds = 10,
170
+ ): () => void {
171
+ const bar = el('div', { class: 'undo-bar', attrs: { role: 'status' } })
172
+ bar.appendChild(el('span', { text: message }))
173
+
174
+ const remove = () => {
175
+ clearTimeout(timer)
176
+ bar.remove()
177
+ }
178
+ bar.appendChild(
179
+ button(
180
+ 'Undo',
181
+ () => {
182
+ remove()
183
+ void undo()
184
+ },
185
+ { class: 'btn' },
186
+ ),
187
+ )
188
+
189
+ const timer = setTimeout(remove, seconds * 1000)
190
+ document.body.appendChild(bar)
191
+ return remove
192
+ }
193
+
194
+ /** A message that is not a question. Used for a refusal and for a failure. */
195
+ export function notify(message: string, kind: 'info' | 'error' = 'info'): void {
196
+ const bar = el('div', {
197
+ class: `undo-bar ${kind}`,
198
+ text: message,
199
+ attrs: { role: kind === 'error' ? 'alert' : 'status' },
200
+ })
201
+ document.body.appendChild(bar)
202
+ setTimeout(() => bar.remove(), 6000)
203
+ }