@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,185 @@
1
+ /**
2
+ * The last step: the bad-row policy, and sending.
3
+ *
4
+ * The interesting part is `commit`. It chunks, and the Cancel button stops
5
+ * **before** the next request rather than aborting one in flight — cancelling
6
+ * mid-flight would leave the user unable to say what landed, and this way the
7
+ * answer is exact and is reported: the chunks that completed are the rows that
8
+ * are in.
9
+ */
10
+
11
+ import { type ImportResult, importRows } from './api'
12
+ import { notify } from './confirm'
13
+ import {
14
+ type BadRowPolicy,
15
+ buildRecords,
16
+ chunk,
17
+ type ImportModel,
18
+ issuesOf,
19
+ rejectedCSV,
20
+ setBadRowPolicy,
21
+ } from './csv-model'
22
+ import { append, box, button, downloadText, el } from './dom'
23
+ import type { SchemaColumn, SchemaTable } from './meta'
24
+
25
+ /** One request per chunk. Well under `policy.ts`'s 50,000 row ceiling. */
26
+ const CHUNK = 500
27
+
28
+ export interface CommitContext {
29
+ table: SchemaTable
30
+ columns: SchemaColumn[]
31
+ reload: () => Promise<void>
32
+ }
33
+
34
+ type Failures = ReturnType<typeof buildRecords>['failures']
35
+
36
+ export function paintFooter(
37
+ node: HTMLElement,
38
+ ctx: CommitContext,
39
+ model: ImportModel,
40
+ update: (next: ImportModel) => void,
41
+ onClose: () => void,
42
+ stage: HTMLElement,
43
+ ): void {
44
+ node.replaceChildren()
45
+ const issues = issuesOf(model, ctx.columns)
46
+ const built = buildRecords(model, ctx.columns)
47
+
48
+ const policies: { value: BadRowPolicy; label: string }[] = [
49
+ { value: 'skip', label: 'skip bad rows and report' },
50
+ { value: 'stop', label: 'stop at the first bad row' },
51
+ { value: 'all', label: 'all or nothing' },
52
+ ]
53
+ const picker = policyPicker(policies, model, update)
54
+
55
+ const count = el('span', {
56
+ class: built.failures.length ? 'row-error' : 'note',
57
+ text: `${built.records.length} rows ready · ${built.failures.length} bad`,
58
+ })
59
+
60
+ const go = button(
61
+ 'Import',
62
+ () =>
63
+ void commit(stage, ctx, model, built.records, built.failures, onClose),
64
+ { class: 'btn primary' },
65
+ )
66
+ go.disabled = issues.blocking.length > 0 || built.records.length === 0
67
+
68
+ append(node, [picker, count, button('Cancel', onClose, { class: 'btn' }), go])
69
+ }
70
+
71
+ function policyPicker(
72
+ policies: readonly { value: BadRowPolicy; label: string }[],
73
+ model: ImportModel,
74
+ update: (next: ImportModel) => void,
75
+ ): HTMLSelectElement {
76
+ const node = el('select', { class: 'sel' })
77
+ for (const policy of policies) {
78
+ const option = el('option', { text: policy.label })
79
+ option.value = policy.value
80
+ option.selected = policy.value === model.onBadRow
81
+ node.appendChild(option)
82
+ }
83
+ node.addEventListener('change', () =>
84
+ update(setBadRowPolicy(model, node.value as BadRowPolicy)),
85
+ )
86
+ return node
87
+ }
88
+
89
+ async function commit(
90
+ stage: HTMLElement,
91
+ ctx: CommitContext,
92
+ model: ImportModel,
93
+ records: Record<string, unknown>[],
94
+ failures: Failures,
95
+ onClose: () => void,
96
+ ): Promise<void> {
97
+ stage.replaceChildren()
98
+ const progress = el('p', { text: `0 / ${records.length}` })
99
+ let cancelled = false
100
+ const stop = button(
101
+ 'Cancel',
102
+ () => {
103
+ cancelled = true
104
+ },
105
+ { class: 'btn' },
106
+ )
107
+ append(stage, [progress, box('row-bar', stop)])
108
+
109
+ // All-or-nothing means one transaction, and one transaction means one
110
+ // request — chunking it would produce N transactions and exactly the partial
111
+ // apply the option exists to rule out.
112
+ const batches = model.onBadRow === 'all' ? [records] : chunk(records, CHUNK)
113
+ const onBadRow = model.onBadRow === 'skip' ? 'skip' : 'stop'
114
+
115
+ let inserted = 0
116
+ for (const batch of batches) {
117
+ if (cancelled) break
118
+ const result = await sendBatch(ctx, batch, onBadRow)
119
+ if (!result) break
120
+ inserted += result.inserted
121
+ progress.textContent = `${inserted} / ${records.length}`
122
+ }
123
+
124
+ paintDone(stage, ctx, model, failures, {
125
+ cancelled,
126
+ inserted,
127
+ total: records.length,
128
+ onClose,
129
+ })
130
+ }
131
+
132
+ interface DoneFacts {
133
+ cancelled: boolean
134
+ inserted: number
135
+ total: number
136
+ onClose: () => void
137
+ }
138
+
139
+ function paintDone(
140
+ stage: HTMLElement,
141
+ ctx: CommitContext,
142
+ model: ImportModel,
143
+ failures: Failures,
144
+ facts: DoneFacts,
145
+ ): void {
146
+ const done = button(
147
+ 'Done',
148
+ () => {
149
+ void ctx.reload()
150
+ facts.onClose()
151
+ },
152
+ { class: 'btn primary' },
153
+ )
154
+ stage.replaceChildren()
155
+ append(stage, [
156
+ el('p', {
157
+ text: facts.cancelled
158
+ ? `Cancelled — ${facts.inserted} rows landed before it stopped.`
159
+ : `${facts.inserted} rows imported.`,
160
+ }),
161
+ failures.length ? rejectedDownload(model, failures) : null,
162
+ box('row-bar', done),
163
+ ])
164
+ }
165
+
166
+ async function sendBatch(
167
+ ctx: CommitContext,
168
+ rows: Record<string, unknown>[],
169
+ onBadRow: 'stop' | 'skip',
170
+ ): Promise<ImportResult | null> {
171
+ try {
172
+ return await importRows({ table: ctx.table.name, rows, onBadRow })
173
+ } catch (error) {
174
+ notify((error as Error)?.message ?? 'import failed', 'error')
175
+ return null
176
+ }
177
+ }
178
+
179
+ function rejectedDownload(model: ImportModel, failures: Failures): HTMLElement {
180
+ return button(
181
+ `Download ${failures.length} rejected rows`,
182
+ () => downloadText('rejected.csv', rejectedCSV(model, failures)),
183
+ { class: 'btn' },
184
+ )
185
+ }
@@ -0,0 +1,274 @@
1
+ /**
2
+ * Steps two and three: how the file was parsed, and where each column goes.
3
+ *
4
+ * Nothing here inspects a `<select>` to work out what the mapping is — the
5
+ * mapping *is* the model, and every control writes a whole new one through
6
+ * `update`. `csv-model.ts` holds all of it and is pure.
7
+ */
8
+
9
+ import {
10
+ type Assignment,
11
+ buildModel,
12
+ type ImportModel,
13
+ issuesOf,
14
+ reassign,
15
+ } from './csv-model'
16
+ import { append, box, each, el, on, select } from './dom'
17
+ import type { SchemaColumn } from './meta'
18
+
19
+ export const SKIP_VALUE = ' skip'
20
+ export const CONST_VALUE = ' const'
21
+
22
+ export interface MapContext {
23
+ columns: SchemaColumn[]
24
+ }
25
+
26
+ export interface Reparse {
27
+ source: string
28
+ delimiter: string
29
+ hasHeader: boolean
30
+ }
31
+
32
+ /**
33
+ * Delimiter and header, both sniffed and both overridable.
34
+ *
35
+ * Re-parsing needs the original text, which the model does not keep — so the
36
+ * source is rebuilt from the parsed rows. That is lossless for the purpose:
37
+ * changing the delimiter after the fact re-splits fields that the wrong
38
+ * delimiter merged, and the merged text is exactly what was in the file.
39
+ */
40
+ export function paintHead(
41
+ node: HTMLElement,
42
+ model: ImportModel,
43
+ onReparse: (next: Reparse) => void,
44
+ ): void {
45
+ node.replaceChildren()
46
+ const source = () => rebuildSource(model)
47
+
48
+ const delimiters = [
49
+ { value: ',', label: 'comma ,' },
50
+ { value: ';', label: 'semicolon ;' },
51
+ { value: '\t', label: 'tab' },
52
+ { value: '|', label: 'pipe |' },
53
+ ]
54
+ const picker = select(delimiters, model.delimiter, value =>
55
+ onReparse({
56
+ source: source(),
57
+ delimiter: value,
58
+ hasHeader: model.hasHeader,
59
+ }),
60
+ )
61
+
62
+ const header = el('input')
63
+ header.type = 'checkbox'
64
+ header.checked = model.hasHeader
65
+ on(header, 'change', () =>
66
+ onReparse({
67
+ source: source(),
68
+ delimiter: model.delimiter,
69
+ hasHeader: header.checked,
70
+ }),
71
+ )
72
+ const headerLabel = el('label', {
73
+ class: 'note',
74
+ text: ' first row is a header',
75
+ })
76
+ headerLabel.prepend(header)
77
+
78
+ append(node, [
79
+ el('span', { class: 'note', text: 'delimiter' }),
80
+ picker,
81
+ headerLabel,
82
+ el('span', { class: 'note', text: summaryText(model) }),
83
+ ])
84
+ }
85
+
86
+ function summaryText(model: ImportModel): string {
87
+ const base = `${model.rows.length} rows · ${model.headers.length} columns`
88
+ return model.ragged.length ? `${base} · ${model.ragged.length} ragged` : base
89
+ }
90
+
91
+ /**
92
+ * The original text, near enough to re-split.
93
+ *
94
+ * Fields are re-quoted with the model's current delimiter so a value that
95
+ * itself contains the *new* delimiter survives the round trip. This is why the
96
+ * wizard can offer a delimiter override at all without holding the file.
97
+ */
98
+ export function rebuildSource(model: ImportModel): string {
99
+ const quote = (field: string) =>
100
+ /["\n\r]/.test(field) || field.includes(model.delimiter)
101
+ ? `"${field.replace(/"/g, '""')}"`
102
+ : field
103
+ const lines = model.rows.map(row => row.map(quote).join(model.delimiter))
104
+ const head = model.hasHeader
105
+ ? [model.headers.map(quote).join(model.delimiter)]
106
+ : []
107
+ return [...head, ...lines].join('\n')
108
+ }
109
+
110
+ /** A `Reparse` applied. Kept here so `csv.ts` holds no parsing knowledge. */
111
+ export function reparse(
112
+ next: Reparse,
113
+ columns: readonly SchemaColumn[],
114
+ ): ImportModel {
115
+ return buildModel(next.source, columns, {
116
+ delimiter: next.delimiter,
117
+ hasHeader: next.hasHeader,
118
+ })
119
+ }
120
+
121
+ // -------------------------------------------------------------- the mapping
122
+
123
+ export function paintMapping(
124
+ node: HTMLElement,
125
+ ctx: MapContext,
126
+ model: ImportModel,
127
+ update: (next: ImportModel) => void,
128
+ ): void {
129
+ node.replaceChildren()
130
+ node.appendChild(el('h4', { text: 'Mapping' }))
131
+ each(node, model.headers, header => mappingRow(ctx, model, header, update))
132
+ }
133
+
134
+ /**
135
+ * One CSV column: its name, three samples, and where it goes.
136
+ *
137
+ * Re-picking a database column already taken **moves** it — `reassign` clears
138
+ * the previous holder — so a duplicate mapping cannot be expressed here at all,
139
+ * rather than being flagged after the fact.
140
+ */
141
+ function mappingRow(
142
+ ctx: MapContext,
143
+ model: ImportModel,
144
+ header: string,
145
+ update: (next: ImportModel) => void,
146
+ ): HTMLElement {
147
+ const assignment = model.assign[header] ?? { kind: 'skip' as const }
148
+ const row = box('map-row')
149
+
150
+ const options = [
151
+ { value: SKIP_VALUE, label: '— skip —' },
152
+ { value: CONST_VALUE, label: '— constant… —' },
153
+ ...ctx.columns.map(column => ({
154
+ value: column.name,
155
+ label: `${column.name} · ${column.type}`,
156
+ })),
157
+ ]
158
+ const picker = select(options, currentValue(assignment), value =>
159
+ update(reassign(model, header, assignmentFor(value, assignment))),
160
+ )
161
+
162
+ append(row, [
163
+ el('div', { class: 'map-name', text: header }),
164
+ el('div', { class: 'map-samples note', text: sampleText(model, header) }),
165
+ picker,
166
+ ])
167
+ if (assignment.kind === 'constant') {
168
+ append(row, constantControls(ctx, model, header, assignment, update))
169
+ }
170
+ return row
171
+ }
172
+
173
+ function currentValue(assignment: Assignment): string {
174
+ if (assignment.kind === 'column') return assignment.column
175
+ return assignment.kind === 'constant' ? CONST_VALUE : SKIP_VALUE
176
+ }
177
+
178
+ export function assignmentFor(value: string, previous: Assignment): Assignment {
179
+ if (value === SKIP_VALUE) return { kind: 'skip' }
180
+ if (value === CONST_VALUE) {
181
+ return {
182
+ kind: 'constant',
183
+ column: previous.kind === 'column' ? previous.column : null,
184
+ text: previous.kind === 'constant' ? previous.text : '',
185
+ }
186
+ }
187
+ return { kind: 'column', column: value }
188
+ }
189
+
190
+ /**
191
+ * A constant needs two things a column mapping does not: which column it feeds,
192
+ * and what the literal is. The CSV column's own values are ignored.
193
+ */
194
+ function constantControls(
195
+ ctx: MapContext,
196
+ model: ImportModel,
197
+ header: string,
198
+ assignment: Extract<Assignment, { kind: 'constant' }>,
199
+ update: (next: ImportModel) => void,
200
+ ): HTMLElement[] {
201
+ const target = select(
202
+ [
203
+ { value: SKIP_VALUE, label: '— into which column —' },
204
+ ...ctx.columns.map(column => ({
205
+ value: column.name,
206
+ label: column.name,
207
+ })),
208
+ ],
209
+ assignment.column ?? SKIP_VALUE,
210
+ value =>
211
+ update(
212
+ reassign(model, header, {
213
+ ...assignment,
214
+ column: value === SKIP_VALUE ? null : value,
215
+ }),
216
+ ),
217
+ )
218
+ const literal = el('input', { class: 'ed' })
219
+ literal.type = 'text'
220
+ literal.value = assignment.text
221
+ literal.placeholder = 'constant value'
222
+ on(literal, 'change', () =>
223
+ update(reassign(model, header, { ...assignment, text: literal.value })),
224
+ )
225
+ return [target, literal]
226
+ }
227
+
228
+ function sampleText(model: ImportModel, header: string): string {
229
+ const index = model.headers.indexOf(header)
230
+ if (index < 0) return ''
231
+ return model.rows
232
+ .slice(0, 3)
233
+ .map(row => row[index] ?? '')
234
+ .map(value => (value === '' ? '␀' : value))
235
+ .join(' · ')
236
+ }
237
+
238
+ /**
239
+ * Database columns nobody feeds, tagged with whether that matters.
240
+ *
241
+ * `blockingIssues` decides `required`; this only renders the answer. A required
242
+ * column names itself here *and* disables the Import button, because a list the
243
+ * user can scroll past is not a block.
244
+ */
245
+ export function paintUnmapped(
246
+ node: HTMLElement,
247
+ ctx: MapContext,
248
+ model: ImportModel,
249
+ ): void {
250
+ node.replaceChildren()
251
+ const issues = issuesOf(model, ctx.columns)
252
+
253
+ node.appendChild(el('h4', { text: 'Database columns' }))
254
+ each(node, issues.unmapped, entry => {
255
+ const row = box('unmapped-row')
256
+ row.appendChild(
257
+ el('span', { class: `tag ${entry.status}`, text: entry.status }),
258
+ )
259
+ row.appendChild(el('span', { text: entry.column }))
260
+ return row
261
+ })
262
+
263
+ if (issues.unmatched.length) {
264
+ node.appendChild(
265
+ el('p', {
266
+ class: 'note',
267
+ text: `${issues.unmatched.length} CSV columns are not imported: ${issues.unmatched.join(', ')}`,
268
+ }),
269
+ )
270
+ }
271
+ each(node, issues.blocking, issue =>
272
+ el('p', { class: 'row-error', text: issue.message }),
273
+ )
274
+ }