@bakery-framework/plugin-db-explorer 2.0.0-alpha.5 → 2.0.0-alpha.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/package.json +4 -4
  2. package/src/access.ts +188 -0
  3. package/src/client/api.ts +261 -0
  4. package/src/client/bulk.ts +361 -0
  5. package/src/client/cell.ts +139 -0
  6. package/src/client/confirm.ts +201 -0
  7. package/src/client/csv-commit.ts +185 -0
  8. package/src/client/csv-map.ts +274 -0
  9. package/src/client/csv-model.ts +420 -0
  10. package/src/client/csv-pick.ts +54 -0
  11. package/src/client/csv-preview.ts +91 -0
  12. package/src/client/csv.ts +104 -0
  13. package/src/client/dom.ts +144 -0
  14. package/src/client/edit-session.ts +219 -0
  15. package/src/client/editors.ts +283 -0
  16. package/src/client/filter-builder.ts +198 -0
  17. package/src/client/fk.ts +242 -0
  18. package/src/client/grid-body.ts +103 -0
  19. package/src/client/grid-header.ts +65 -0
  20. package/src/client/grid-rowbar.ts +64 -0
  21. package/src/client/grid.ts +466 -0
  22. package/src/client/meta.ts +188 -0
  23. package/src/client/page.ts +332 -0
  24. package/src/client/panel.ts +296 -0
  25. package/src/client/relations.ts +205 -0
  26. package/src/client/save.ts +209 -0
  27. package/src/client/sidebar.ts +110 -0
  28. package/src/client/state.ts +218 -0
  29. package/src/client/statusbar.ts +130 -0
  30. package/src/client/structure.ts +231 -0
  31. package/src/client/tabs.ts +224 -0
  32. package/src/client/tabstrip.ts +127 -0
  33. package/src/client.ts +374 -160
  34. package/src/endpoints/common.ts +122 -0
  35. package/src/endpoints/graph.ts +0 -0
  36. package/src/endpoints/import.ts +89 -0
  37. package/src/endpoints/read.ts +173 -0
  38. package/src/endpoints/rows.ts +435 -0
  39. package/src/identity.ts +391 -0
  40. package/src/index.ts +40 -45
  41. package/src/policy.ts +45 -0
  42. package/src/preview.ts +53 -0
  43. package/src/setup.ts +64 -81
  44. package/src/shared/coerce.ts +399 -0
  45. package/src/shared/csv.ts +235 -0
  46. package/src/shared/filters.ts +200 -0
  47. package/src/shared/plan.ts +164 -0
  48. package/src/shell.ts +187 -0
  49. package/src/validate.ts +295 -0
  50. package/src/credential.ts +0 -26
  51. package/src/endpoints.ts +0 -48
@@ -0,0 +1,435 @@
1
+ /**
2
+ * The row write surface: insert, edit one, edit many, delete.
3
+ *
4
+ * **Every statement carries an explicit identity predicate**, built from the
5
+ * key the caller sent and checked against the table's declared identity first.
6
+ * The adapter's `update(table, rowid, row)` / `remove(table, rowid)` triple is
7
+ * never used — see the header of `identity.ts` for the three ways it is wrong.
8
+ *
9
+ * **Optimistic concurrency** is the same predicate with `expect` appended:
10
+ * identity ∧ expect. `changes === 0` therefore means one of two things, and the
11
+ * dialects will not tell them apart — *the row moved on* or *the update was a
12
+ * no-op*. MySQL reports 0 changed rows when an UPDATE sets a column to the
13
+ * value it already held, so a zero has to be probed rather than trusted, and
14
+ * the probe runs inside the same transaction as the UPDATE or it is answering
15
+ * about a different moment.
16
+ */
17
+
18
+ import { Try } from '@bakery-framework/core/utils'
19
+ import type { JsonResponseData } from '@bakery-framework/core/utils/common'
20
+ import { response } from '@bakery-framework/core/utils/http'
21
+ import { getActiveDb } from '@bakery-framework/orm/connection'
22
+ import { DB } from '@bakery-framework/orm/orm'
23
+ import { qId } from '@bakery-framework/orm/schema-util'
24
+ import type { TableFacts } from '../identity'
25
+ import { overLimit } from '../policy'
26
+ import { conflictRollback, isRollbackSignal, previewRollback } from '../preview'
27
+ import {
28
+ type FieldError,
29
+ isRecord,
30
+ unguardableColumns,
31
+ validateInsertRow,
32
+ validateKey,
33
+ validatePartial,
34
+ } from '../validate'
35
+ import { beginWrite, type Envelope, invalid } from './common'
36
+
37
+ /**
38
+ * A conflict, as the caller sees it: which edit, which row, and what the row
39
+ * looks like now so the client can offer a diff rather than "try again".
40
+ */
41
+ export interface Conflict {
42
+ index: number
43
+ key: Record<string, unknown>
44
+ reason: string
45
+ row: Record<string, unknown> | null
46
+ }
47
+
48
+ /**
49
+ * One row by its identity, read through the active connection — which inside
50
+ * `DB.transaction` is the transaction's own handle, not the pooled one.
51
+ *
52
+ * Built with `qId` and bound parameters (convention 8). `IS NULL` rather than
53
+ * `= ?` for a null, because `NULL = NULL` is unknown and the predicate would
54
+ * match nothing.
55
+ */
56
+ async function selectRow(
57
+ table: TableFacts,
58
+ predicate: Record<string, unknown>,
59
+ ): Promise<Record<string, unknown> | null> {
60
+ const columns = Object.keys(predicate)
61
+ if (!columns.length) return null
62
+
63
+ const params: unknown[] = []
64
+ const where = columns
65
+ .map(column => {
66
+ const value = predicate[column]
67
+ if (value === null) return `${qId(column)} IS NULL`
68
+ params.push(value)
69
+ return `${qId(column)} = ?`
70
+ })
71
+ .join(' AND ')
72
+
73
+ const row = await getActiveDb()
74
+ .query(`SELECT * FROM ${qId(table.name)} WHERE ${where} LIMIT 1`)
75
+ .get(...params)
76
+ return (row as Record<string, unknown> | undefined) ?? null
77
+ }
78
+
79
+ /**
80
+ * `where(a).and(b).and(c)…` over a predicate of any width.
81
+ *
82
+ * `any` for the value, because the builder's `WhereValue` is a union that also
83
+ * carries column references and subqueries — narrowing to it here would mean
84
+ * asserting a coerced database value is not one of those, which is true but
85
+ * unprovable at this end. The values themselves are already bound parameters.
86
+ */
87
+ function chain<E extends { and(column: any, value?: any): E }>(
88
+ begin: (column: string, value: any) => E,
89
+ predicate: Record<string, any>,
90
+ ): E {
91
+ const entries = Object.entries(predicate)
92
+ const [first, rest] = [entries[0]!, entries.slice(1)]
93
+ let executable = begin(first[0], first[1])
94
+ for (const [column, value] of rest) executable = executable.and(column, value)
95
+ return executable
96
+ }
97
+
98
+ /**
99
+ * Turn a `RollbackSignal` back into a response, and rethrow anything else.
100
+ *
101
+ * The rethrow is the load-bearing half. A `catch` that answered 200 for every
102
+ * throw would report a failed write as a successful dry run — see `preview.ts`.
103
+ */
104
+ function fromRollback(error: any): Envelope {
105
+ if (isRollbackSignal(error)) {
106
+ return error.status >= 400
107
+ ? response.json.error(error.status, error.message, error.report)
108
+ : response.json.success(error.message, error.report, error.status)
109
+ }
110
+ return response.json.error(400, error?.message ?? 'The write failed')
111
+ }
112
+
113
+ // ---------------------------------------------------------------- POST /rows
114
+
115
+ export async function handleInsertRows(
116
+ req: Request,
117
+ ): Promise<JsonResponseData<unknown>> {
118
+ const start = await beginWrite(req)
119
+ if (!start.ok) return start.response
120
+ const { table, body } = start
121
+
122
+ const rows = body.rows
123
+ if (!Array.isArray(rows) || !rows.length) {
124
+ return response.json.error(400, 'rows must be a non-empty array')
125
+ }
126
+
127
+ // Before validation and before any statement: a 413 has to be true when it
128
+ // says nothing was executed, and validating 100,000 rows to then refuse them
129
+ // is work done on behalf of a request that was never going to run.
130
+ const over = overLimit('insertRows', rows.length)
131
+ if (over) return response.json.error(413, over)
132
+
133
+ const errors: FieldError[] = []
134
+ const records: Record<string, unknown>[] = []
135
+ rows.forEach((row, index) => {
136
+ const validated = validateInsertRow(row, table, index)
137
+ errors.push(...validated.errors)
138
+ records.push(validated.values)
139
+ })
140
+ if (errors.length) return invalid(errors)
141
+
142
+ const returning = body.returning === true
143
+
144
+ return await Try.return(
145
+ async () => {
146
+ // `DB.Insert` already batches under the adapter's parameter ceiling and
147
+ // wraps multiple batches in one transaction, so there is nothing to add
148
+ // here — which is exactly why the insert goes through it rather than
149
+ // through the adapter's own `insert()`.
150
+ const insert = DB.Insert.into(table.name).values(records)
151
+ if (!returning) {
152
+ const result = await insert.run()
153
+ return response.json.success('inserted', {
154
+ inserted: Number(result.changes ?? 0),
155
+ })
156
+ }
157
+ // `RETURNING` is SQLite and Postgres only — MySQL has no such clause and
158
+ // answers with its own syntax error, which is loud and correct. It is not
159
+ // emulated: a re-SELECT would have to guess the generated keys, and
160
+ // guessing which rows were just written is the class of bug this whole
161
+ // module exists to avoid.
162
+ const written = await insert
163
+ .returning('*')
164
+ .array<Record<string, unknown>>()
165
+ return response.json.success('inserted', {
166
+ inserted: written.length,
167
+ rows: written,
168
+ })
169
+ },
170
+ (error: any) => response.json.error(400, error?.message ?? 'Insert failed'),
171
+ )
172
+ }
173
+
174
+ // --------------------------------------------------------------- PATCH /row
175
+
176
+ export async function handleUpdateRow(
177
+ req: Request,
178
+ ): Promise<JsonResponseData<unknown>> {
179
+ const start = await beginWrite(req)
180
+ if (!start.ok) return start.response
181
+ const { table, body } = start
182
+
183
+ if (!isRecord(body.expect)) {
184
+ // Required, not optional. An update with no `expect` is a last-write-wins
185
+ // update, and making that the default is how two people editing the same
186
+ // row silently lose one of the two edits. `{}` is the explicit spelling of
187
+ // "I accept that".
188
+ return response.json.error(
189
+ 400,
190
+ 'expect is required — send {} to update without a concurrency check',
191
+ )
192
+ }
193
+
194
+ const key = validateKey(body.key, table, 0)
195
+ const set = validatePartial(body.set, table, 0, {
196
+ allowUncomparable: true,
197
+ label: 'set',
198
+ })
199
+ const expect = validatePartial(body.expect, table, 0, {
200
+ allowUncomparable: false,
201
+ label: 'expect',
202
+ })
203
+
204
+ const errors = [...key.errors, ...set.errors, ...expect.errors]
205
+ if (errors.length) return invalid(errors)
206
+ if (!Object.keys(set.values).length) {
207
+ return response.json.error(400, 'set names no columns')
208
+ }
209
+
210
+ const unguardable = unguardableColumns(set.values, table)
211
+ if (unguardable.length && body.force !== true) {
212
+ return response.json.error(
213
+ 400,
214
+ `${unguardable.join(', ')} cannot be guarded by expect; ` +
215
+ 'pass force: true to overwrite without a concurrency check',
216
+ )
217
+ }
218
+
219
+ return await Try.return(
220
+ async () =>
221
+ await DB.transaction(async () => {
222
+ const predicate = { ...key.where, ...expect.values }
223
+ const result = await chain(
224
+ (column, value) =>
225
+ DB.Update.table(table.name).set(set.values).where(column, value),
226
+ predicate,
227
+ ).run()
228
+
229
+ const changed = Number(result.changes ?? 0)
230
+ if (changed > 0) {
231
+ return response.json.success('updated', {
232
+ changed,
233
+ row: await selectRow(table, key.where),
234
+ })
235
+ }
236
+
237
+ // Zero. Probe, in this transaction, before calling it a conflict:
238
+ // MySQL reports zero changed rows for an UPDATE that set every column
239
+ // to the value it already held, which is a successful no-op and not a
240
+ // lost update. If the row still satisfies identity ∧ expect, that is
241
+ // what happened.
242
+ const unchanged = await selectRow(table, predicate)
243
+ if (unchanged) {
244
+ return response.json.success('unchanged', {
245
+ changed: 0,
246
+ row: unchanged,
247
+ })
248
+ }
249
+
250
+ return response.json.error(409, 'The row changed since it was read', {
251
+ changed: 0,
252
+ row: await selectRow(table, key.where),
253
+ })
254
+ }),
255
+ fromRollback,
256
+ )
257
+ }
258
+
259
+ // ---------------------------------------------------------- POST /rows/bulk
260
+
261
+ export async function handleBulkEdit(
262
+ req: Request,
263
+ ): Promise<JsonResponseData<unknown>> {
264
+ const start = await beginWrite(req)
265
+ if (!start.ok) return start.response
266
+ const { table, body } = start
267
+
268
+ const edits = body.edits
269
+ if (!Array.isArray(edits) || !edits.length) {
270
+ return response.json.error(400, 'edits must be a non-empty array')
271
+ }
272
+ const over = overLimit('bulkEdits', edits.length)
273
+ if (over) return response.json.error(413, over)
274
+
275
+ const errors: FieldError[] = []
276
+ const prepared = edits.map((edit, index) => {
277
+ if (!isRecord(edit)) {
278
+ errors.push({
279
+ row: index,
280
+ column: '',
281
+ code: 'not_an_edit',
282
+ message: 'expected an object',
283
+ })
284
+ return null
285
+ }
286
+ const key = validateKey(edit.key, table, index)
287
+ const set = validatePartial(edit.set, table, index, {
288
+ allowUncomparable: true,
289
+ label: 'set',
290
+ })
291
+ const expect = validatePartial(edit.expect ?? {}, table, index, {
292
+ allowUncomparable: false,
293
+ label: 'expect',
294
+ })
295
+ errors.push(...key.errors, ...set.errors, ...expect.errors)
296
+ if (!Object.keys(set.values).length) {
297
+ errors.push({
298
+ row: index,
299
+ column: '',
300
+ code: 'empty_set',
301
+ message: 'set names no columns',
302
+ })
303
+ }
304
+ return { key: key.where, set: set.values, expect: expect.values }
305
+ })
306
+ if (errors.length) return invalid(errors)
307
+
308
+ const dryRun = body.dryRun === true
309
+
310
+ return await Try.return(
311
+ async () =>
312
+ await DB.transaction(async () => {
313
+ const conflicts: Conflict[] = []
314
+ let changed = 0
315
+
316
+ for (let index = 0; index < prepared.length; index++) {
317
+ const edit = prepared[index]!
318
+ const predicate = { ...edit.key, ...edit.expect }
319
+ const result = await chain(
320
+ (column, value) =>
321
+ DB.Update.table(table.name).set(edit.set).where(column, value),
322
+ predicate,
323
+ ).run()
324
+
325
+ const rows = Number(result.changes ?? 0)
326
+ if (rows > 0) {
327
+ changed += rows
328
+ continue
329
+ }
330
+ // Same MySQL no-op probe as the single-row path.
331
+ if (await selectRow(table, predicate)) continue
332
+ conflicts.push({
333
+ index,
334
+ key: edit.key,
335
+ reason: 'the row changed since it was read',
336
+ row: await selectRow(table, edit.key),
337
+ })
338
+ }
339
+
340
+ // **All or nothing.** A bulk edit is one action from the user's side,
341
+ // and a partial apply leaves them with no way to know which half
342
+ // landed — the retry then double-applies whatever succeeded. Any
343
+ // conflict rolls the whole transaction back.
344
+ if (conflicts.length) conflictRollback({ changed: 0, conflicts })
345
+ if (dryRun) previewRollback({ changed, conflicts })
346
+ return response.json.success('updated', { changed, conflicts })
347
+ }),
348
+ fromRollback,
349
+ )
350
+ }
351
+
352
+ // -------------------------------------------------------------- DELETE /rows
353
+
354
+ export async function handleDeleteRows(
355
+ req: Request,
356
+ ): Promise<JsonResponseData<unknown>> {
357
+ const start = await beginWrite(req)
358
+ if (!start.ok) return start.response
359
+ const { table, body } = start
360
+
361
+ const keys = body.keys
362
+ if (!Array.isArray(keys) || !keys.length) {
363
+ return response.json.error(400, 'keys must be a non-empty array')
364
+ }
365
+ const over = overLimit('deleteKeys', keys.length)
366
+ if (over) return response.json.error(413, over)
367
+
368
+ // Parallel to `keys`, not one shared object: a delete guarded by "the row
369
+ // still looks like this" needs a different expectation per row, and a single
370
+ // shared one would only ever be right for a single-row delete.
371
+ const expectations = body.expect
372
+ if (expectations !== undefined) {
373
+ if (!Array.isArray(expectations) || expectations.length !== keys.length) {
374
+ return response.json.error(
375
+ 400,
376
+ 'expect must be an array parallel to keys',
377
+ )
378
+ }
379
+ }
380
+
381
+ const errors: FieldError[] = []
382
+ const prepared = keys.map((key, index) => {
383
+ const validated = validateKey(key, table, index)
384
+ errors.push(...validated.errors)
385
+ const raw = Array.isArray(expectations) ? expectations[index] : undefined
386
+ const expect =
387
+ raw === undefined || raw === null
388
+ ? { values: {}, errors: [] }
389
+ : validatePartial(raw, table, index, {
390
+ allowUncomparable: false,
391
+ label: 'expect',
392
+ })
393
+ errors.push(...expect.errors)
394
+ return { key: validated.where, expect: expect.values }
395
+ })
396
+ if (errors.length) return invalid(errors)
397
+
398
+ const dryRun = body.dryRun === true
399
+
400
+ return await Try.return(
401
+ async () =>
402
+ await DB.transaction(async () => {
403
+ const conflicts: Conflict[] = []
404
+ let deleted = 0
405
+
406
+ for (let index = 0; index < prepared.length; index++) {
407
+ const target = prepared[index]!
408
+ const predicate = { ...target.key, ...target.expect }
409
+ const result = await chain(
410
+ (column, value) => DB.Delete.from(table.name).where(column, value),
411
+ predicate,
412
+ ).run()
413
+
414
+ const rows = Number(result.changes ?? 0)
415
+ if (rows > 0) {
416
+ deleted += rows
417
+ continue
418
+ }
419
+ // No no-op case here: a DELETE that matched a row always reports it.
420
+ // Zero means the row is not there, or no longer matches `expect`.
421
+ conflicts.push({
422
+ index,
423
+ key: target.key,
424
+ reason: 'the row is gone or no longer matches expect',
425
+ row: await selectRow(table, target.key),
426
+ })
427
+ }
428
+
429
+ if (conflicts.length) conflictRollback({ deleted: 0, conflicts })
430
+ if (dryRun) previewRollback({ deleted, conflicts })
431
+ return response.json.success('deleted', { deleted, conflicts })
432
+ }),
433
+ fromRollback,
434
+ )
435
+ }