@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,122 @@
1
+ /**
2
+ * The preamble every write endpoint shares, in the order the checks have to
3
+ * happen.
4
+ *
5
+ * 1. **`currentCanWrite()`** — a `read` caller is refused before the body is
6
+ * even parsed, so nothing about the request can influence the answer.
7
+ * 2. **The body** — JSON, an object, naming a table.
8
+ * 3. **The table** — 404 if it is not there.
9
+ * 4. **The identity** — 409 if the table has none. A table with no primary
10
+ * key and no all-NOT-NULL unique index is read-only for everyone,
11
+ * including a `write` caller, because there is no way to name one of its
12
+ * rows. See `identity.ts`.
13
+ *
14
+ * Bounds (413) and validation (400) come after, in each endpoint, because they
15
+ * are about the request's own shape rather than about who is asking.
16
+ */
17
+
18
+ import { Case, 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 { currentCanWrite } from '../access'
22
+ import { introspect, type TableFacts } from '../identity'
23
+ import type { FieldError } from '../validate'
24
+
25
+ export type Envelope = JsonResponseData<unknown>
26
+
27
+ export type WriteStart =
28
+ | { ok: true; table: TableFacts; body: Record<string, unknown> }
29
+ | { ok: false; response: Envelope }
30
+
31
+ /** A 400 carrying every field error, never only the first. */
32
+ export function invalid(errors: FieldError[]): Envelope {
33
+ return response.json.error(400, 'Invalid request', { errors })
34
+ }
35
+
36
+ /**
37
+ * The request body as an object, or `null`.
38
+ *
39
+ * `req.json()` rather than core's `processBody`, which answers `{}` for a body
40
+ * it could not parse — indistinguishable from an empty one, so a truncated
41
+ * upload would be reported as a missing `table` field.
42
+ */
43
+ export async function readBody(
44
+ req: Request,
45
+ ): Promise<Record<string, unknown> | null> {
46
+ const body = await Try.return(
47
+ async () => (await req.json()) as unknown,
48
+ null as unknown,
49
+ )
50
+ return typeof body === 'object' && body !== null && !Array.isArray(body)
51
+ ? (body as Record<string, unknown>)
52
+ : null
53
+ }
54
+
55
+ /**
56
+ * Find a table by the name the caller used.
57
+ *
58
+ * Raw database name first — that is what `/api/_db/schema` renders and what the
59
+ * grid sends back. The camel spelling is accepted as well, because a script
60
+ * written against a typed schema has `orderItems` where the database has
61
+ * `order_items`, and refusing that would be refusing the ORM's own vocabulary.
62
+ */
63
+ export function findTable(
64
+ tables: Map<string, TableFacts>,
65
+ name: string,
66
+ ): TableFacts | undefined {
67
+ const exact = tables.get(name)
68
+ if (exact) return exact
69
+ const camel = Case.camel(name)
70
+ for (const table of tables.values()) {
71
+ if (table.camel === camel) return table
72
+ }
73
+ return undefined
74
+ }
75
+
76
+ export async function beginWrite(req: Request): Promise<WriteStart> {
77
+ // First, and before the body is read. Convention 2: the guard returns the
78
+ // rejection, and it is the caller's job to return it unchanged.
79
+ if (!currentCanWrite()) {
80
+ return {
81
+ ok: false,
82
+ response: response.json.error(403, 'This session may read but not write'),
83
+ }
84
+ }
85
+
86
+ const body = await readBody(req)
87
+ if (!body) {
88
+ return {
89
+ ok: false,
90
+ response: response.json.error(400, 'Expected a JSON object body'),
91
+ }
92
+ }
93
+
94
+ const name = body.table
95
+ if (typeof name !== 'string' || !name) {
96
+ return {
97
+ ok: false,
98
+ response: response.json.error(400, 'table is required'),
99
+ }
100
+ }
101
+
102
+ const tables = await introspect()
103
+ const table = findTable(tables, name)
104
+ if (!table) {
105
+ return {
106
+ ok: false,
107
+ response: response.json.error(404, `No table named ${name}`),
108
+ }
109
+ }
110
+
111
+ if (table.identity.mode === 'none') {
112
+ return {
113
+ ok: false,
114
+ response: response.json.error(
115
+ 409,
116
+ `${table.name} is read-only: ${table.identity.reason}`,
117
+ ),
118
+ }
119
+ }
120
+
121
+ return { ok: true, table, body }
122
+ }
Binary file
@@ -0,0 +1,89 @@
1
+ /**
2
+ * CSV import — the rows, not the file.
3
+ *
4
+ * The parse happens in `shared/csv.ts`, which the browser runs to show a
5
+ * preview and the server runs on whatever it is sent. This endpoint takes rows
6
+ * that are already records, so the mapping the user confirmed in the dialog is
7
+ * what arrives, rather than a file the server re-guesses the columns of.
8
+ *
9
+ * Two things separate it from `POST /api/_db/rows`, and both are about scale:
10
+ * the bound is a spreadsheet's worth of rows rather than an edit's, and a bad
11
+ * row does not have to end the whole import — `onBadRow: 'skip'` reports it and
12
+ * carries on, which is what a 50,000-row file with three malformed lines needs.
13
+ */
14
+
15
+ import { Try } from '@bakery-framework/core/utils'
16
+ import type { JsonResponseData } from '@bakery-framework/core/utils/common'
17
+ import { response } from '@bakery-framework/core/utils/http'
18
+ import { DB } from '@bakery-framework/orm/orm'
19
+ import { overLimit } from '../policy'
20
+ import { isRollbackSignal, previewRollback } from '../preview'
21
+ import { type FieldError, validateInsertRow } from '../validate'
22
+ import { beginWrite, invalid } from './common'
23
+
24
+ export type OnBadRow = 'stop' | 'skip'
25
+
26
+ export async function handleImport(
27
+ req: Request,
28
+ ): Promise<JsonResponseData<unknown>> {
29
+ const start = await beginWrite(req)
30
+ if (!start.ok) return start.response
31
+ const { table, body } = start
32
+
33
+ const rows = body.rows
34
+ if (!Array.isArray(rows) || !rows.length) {
35
+ return response.json.error(400, 'rows must be a non-empty array')
36
+ }
37
+ const over = overLimit('csvRows', rows.length)
38
+ if (over) return response.json.error(413, over)
39
+
40
+ const onBadRow: OnBadRow = body.onBadRow === 'skip' ? 'skip' : 'stop'
41
+ if (body.onBadRow !== 'skip' && body.onBadRow !== 'stop') {
42
+ // Named explicitly rather than defaulted silently: the two answers differ
43
+ // in whether a partially-good file gets partially imported, which is the
44
+ // one decision the caller must have made on purpose.
45
+ return response.json.error(400, "onBadRow must be 'stop' or 'skip'")
46
+ }
47
+
48
+ const errors: FieldError[] = []
49
+ const records: Record<string, unknown>[] = []
50
+ rows.forEach((row, index) => {
51
+ const validated = validateInsertRow(row, table, index)
52
+ if (validated.errors.length) {
53
+ errors.push(...validated.errors)
54
+ return
55
+ }
56
+ records.push(validated.values)
57
+ })
58
+
59
+ // `stop` refuses the whole file before a statement runs — the same "413 with
60
+ // nothing executed" promise, for a 400.
61
+ if (onBadRow === 'stop' && errors.length) return invalid(errors)
62
+
63
+ const skipped = rows.length - records.length
64
+ if (!records.length) {
65
+ return response.json.success('imported', { inserted: 0, skipped, errors })
66
+ }
67
+
68
+ const dryRun = body.dryRun === true
69
+
70
+ return await Try.return(
71
+ async () =>
72
+ await DB.transaction(async () => {
73
+ const result = await DB.Insert.into(table.name).values(records).run()
74
+ const report = {
75
+ inserted: Number(result.changes ?? 0),
76
+ skipped,
77
+ errors,
78
+ }
79
+ if (dryRun) previewRollback(report)
80
+ return response.json.success('imported', report)
81
+ }),
82
+ (error: any) => {
83
+ if (isRollbackSignal(error)) {
84
+ return response.json.success(error.message, error.report, error.status)
85
+ }
86
+ return response.json.error(400, error?.message ?? 'Import failed')
87
+ },
88
+ )
89
+ }
@@ -0,0 +1,173 @@
1
+ /**
2
+ * The two read endpoints.
3
+ *
4
+ * `/api/_db/schema` now answers with more than the schema: the caller's own
5
+ * access level, and per table whether it is writable and why not. The client
6
+ * needs its posture *before* it renders — a grid that draws edit affordances
7
+ * and then discovers on save that the table has no primary key has already
8
+ * wasted the user's work.
9
+ */
10
+
11
+ import { Try } from '@bakery-framework/core/utils'
12
+ import type { JsonResponseData } from '@bakery-framework/core/utils/common'
13
+ import { response } from '@bakery-framework/core/utils/http'
14
+ import { connection } from '@bakery-framework/orm/connection'
15
+ import { currentAccess, currentCanWrite } from '../access'
16
+ import { type Identity, introspect } from '../identity'
17
+ import { parseFilters } from '../shared/filters'
18
+
19
+ export interface SchemaColumn {
20
+ name: string
21
+ /** The database's own type string, unchanged — what the grid shows. */
22
+ type: string
23
+ notnull: boolean
24
+ pk: boolean
25
+ /** What the editor coerces against. See `shared/coerce.ts`. */
26
+ kind: string
27
+ nullable: boolean
28
+ length?: number
29
+ enum?: readonly string[]
30
+ hasDefault: boolean
31
+ autoIncrement?: boolean
32
+ }
33
+
34
+ /** A declared index, as the Structure view lists it. */
35
+ export interface SchemaIndex {
36
+ name: string
37
+ type: string
38
+ cols: string[]
39
+ }
40
+
41
+ export interface SchemaTable {
42
+ name: string
43
+ rowCount: number
44
+ columns: SchemaColumn[]
45
+ identity: Identity
46
+ /**
47
+ * Declared indexes.
48
+ *
49
+ * `introspect()` has always computed these — it walks them to find a usable
50
+ * unique key when there is no primary key — and used to throw them away here.
51
+ * The Structure view is the first thing that shows them, and there is no
52
+ * other endpoint that knows them.
53
+ */
54
+ indexes: SchemaIndex[]
55
+ /** Whether this table is a view. A view has no rows of its own to address. */
56
+ isView: boolean
57
+ writable: boolean
58
+ /** Why not, when `writable` is false. */
59
+ reason?: string
60
+ }
61
+
62
+ export interface SchemaReport {
63
+ access: 'read' | 'write' | false
64
+ tables: SchemaTable[]
65
+ }
66
+
67
+ export async function handleSchema(): Promise<JsonResponseData<unknown>> {
68
+ return await Try.return(
69
+ async () => {
70
+ const tables = await introspect()
71
+ const access = currentAccess()
72
+ const canWrite = currentCanWrite()
73
+
74
+ const report: SchemaReport = {
75
+ access,
76
+ tables: [...tables.values()].map(table => {
77
+ // Two independent reasons a table is not writable, and the caller's
78
+ // level is reported first because it is the one that applies to
79
+ // every table at once.
80
+ const reason = !canWrite
81
+ ? 'this session may read but not write'
82
+ : table.identity.reason
83
+ return {
84
+ name: table.name,
85
+ rowCount: table.rowCount,
86
+ columns: table.columns.map(column => ({
87
+ name: column.name,
88
+ type: column.sqlType,
89
+ notnull: !column.meta.nullable,
90
+ pk: Boolean(column.meta.primary),
91
+ kind: column.meta.kind,
92
+ nullable: column.meta.nullable,
93
+ length: column.meta.length,
94
+ enum: column.meta.enum,
95
+ hasDefault: column.meta.hasDefault,
96
+ autoIncrement: column.meta.autoIncrement,
97
+ })),
98
+ identity: table.identity,
99
+ indexes: table.indexes,
100
+ isView: table.isView,
101
+ writable: canWrite && table.identity.mode !== 'none',
102
+ reason,
103
+ }
104
+ }),
105
+ }
106
+
107
+ return response.json.success('success', report)
108
+ },
109
+ () => response.json.error(500, 'Failed to retrieve schema details'),
110
+ )
111
+ }
112
+
113
+ /** Table names the way the ORM writes them: identifier characters only. */
114
+ const RX_TABLE_NAME = /^[a-zA-Z0-9_]+$/
115
+
116
+ /**
117
+ * Read the `filters` parameter, or say why it cannot be read.
118
+ *
119
+ * Split out because it is the one part of this endpoint with a decision in it,
120
+ * and because both failure modes have to be a 400 rather than a silently empty
121
+ * filter set: `JSON.parse` throwing on a mangled parameter, and `parseFilters`
122
+ * rejecting an operator the ORM would otherwise drop. A dropped filter *widens*
123
+ * the result, and the explorer's Delete acts on a selection made from this
124
+ * view — see the header of `shared/filters.ts`.
125
+ */
126
+ function readFilters(
127
+ url: URL,
128
+ ):
129
+ | { ok: true; filters: Record<string, unknown> }
130
+ | { ok: false; error: string } {
131
+ const raw = url.searchParams.get('filters')
132
+ if (!raw) return { ok: true, filters: {} }
133
+
134
+ let parsed: unknown
135
+ try {
136
+ parsed = JSON.parse(raw)
137
+ } catch {
138
+ // A hand-edited or truncated query string. Named as such rather than
139
+ // treated as "no filters", which would answer a question nobody asked.
140
+ return { ok: false, error: 'filters is not valid JSON' }
141
+ }
142
+
143
+ const checked = parseFilters(parsed)
144
+ return checked.ok
145
+ ? { ok: true, filters: checked.filters }
146
+ : { ok: false, error: checked.error }
147
+ }
148
+
149
+ export async function handleTableData(
150
+ url: URL,
151
+ ): Promise<JsonResponseData<unknown>> {
152
+ const tableName = url.searchParams.get('tableName')
153
+ if (!tableName || !RX_TABLE_NAME.test(tableName)) {
154
+ return response.json.error(400, 'Invalid table name')
155
+ }
156
+
157
+ const filters = readFilters(url)
158
+ if (!filters.ok) return response.json.error(400, filters.error)
159
+
160
+ return await Try.return(
161
+ async () => {
162
+ const data = await connection.getData(tableName, {
163
+ page: Number.parseInt(url.searchParams.get('page') || '1', 10),
164
+ pageSize: Number.parseInt(url.searchParams.get('pageSize') || '50', 10),
165
+ sortBy: url.searchParams.get('sortBy'),
166
+ sortOrder: url.searchParams.get('sortOrder') || 'ASC',
167
+ filters: filters.filters,
168
+ })
169
+ return response.json.success('success', data)
170
+ },
171
+ (error: any) => response.json.error(400, error.message),
172
+ )
173
+ }