@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,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
+ }
@@ -0,0 +1,148 @@
1
+ /**
2
+ * What the grid needs to render a foreign key as something other than a number.
3
+ *
4
+ * `/api/_db/graph` is the map, fetched once: every declared foreign key, every
5
+ * table's identity, and the column worth showing instead of an id.
6
+ * `/api/_db/lookup` resolves actual references — **batched, one query per
7
+ * table**, because the shape this replaces is a `fetch` per visible cell, and a
8
+ * fifty-row page with three foreign keys is a hundred and fifty round trips.
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 { qId } from '@bakery-framework/orm/schema-util'
16
+ import { type Identity, introspect, type TableFacts } from '../identity'
17
+ import { overLimit } from '../policy'
18
+ import { findTable, readBody } from './common'
19
+
20
+ export async function handleGraph(): Promise<JsonResponseData<unknown>> {
21
+ return await Try.return(
22
+ async () => {
23
+ const [tables, foreignKeys] = await Promise.all([
24
+ introspect(),
25
+ // Composites are already grouped by the adapter, keyed by the tuple
26
+ // rather than by constraint name — SQLite reports no name at all.
27
+ connection.getForeignKeys(),
28
+ ])
29
+
30
+ const identity: Record<string, Identity> = {}
31
+ const labels: Record<string, string | null> = {}
32
+ for (const table of tables.values()) {
33
+ identity[table.name] = table.identity
34
+ labels[table.name] = table.label
35
+ }
36
+
37
+ return response.json.success('success', {
38
+ foreignKeys,
39
+ identity,
40
+ labels,
41
+ })
42
+ },
43
+ () => response.json.error(500, 'Failed to read the schema graph'),
44
+ )
45
+ }
46
+
47
+ export interface LookupRef {
48
+ table: string
49
+ key: Record<string, unknown>
50
+ }
51
+
52
+ export interface LookupResult {
53
+ table: string
54
+ key: Record<string, unknown>
55
+ row: Record<string, unknown> | null
56
+ }
57
+
58
+ /** A stable string for a set of identity values, for matching rows to refs. */
59
+ function fingerprint(cols: readonly string[], row: Record<string, unknown>) {
60
+ // `String(...)` rather than the values themselves: the driver may hand back a
61
+ // `1n` for the `1` that was sent, or a string for a BIGINT, and a lookup that
62
+ // failed to match on that would render every foreign key as "missing".
63
+ return cols.map(col => String(row[col] ?? '\0')).join('\x01')
64
+ }
65
+
66
+ export async function handleLookup(
67
+ req: Request,
68
+ ): Promise<JsonResponseData<unknown>> {
69
+ const body = await readBody(req)
70
+ if (!body) return response.json.error(400, 'Expected a JSON object body')
71
+
72
+ const refs = body.refs
73
+ if (!Array.isArray(refs)) {
74
+ return response.json.error(400, 'refs must be an array')
75
+ }
76
+ const over = overLimit('lookupRefs', refs.length)
77
+ if (over) return response.json.error(413, over)
78
+ if (!refs.length) return response.json.success('success', { rows: [] })
79
+
80
+ return await Try.return(
81
+ async () => {
82
+ const tables = await introspect()
83
+
84
+ // Grouped first, queried second. One query per *table*, never one per
85
+ // ref.
86
+ const byTable = new Map<string, { table: TableFacts; refs: number[] }>()
87
+ const parsed: (LookupRef | null)[] = refs.map((ref: any, index) => {
88
+ if (typeof ref?.table !== 'string' || typeof ref?.key !== 'object') {
89
+ return null
90
+ }
91
+ const table = findTable(tables, ref.table)
92
+ if (!table || table.identity.mode === 'none') return null
93
+ const group = byTable.get(table.name) ?? { table, refs: [] }
94
+ group.refs.push(index)
95
+ byTable.set(table.name, group)
96
+ return { table: table.name, key: ref.key as Record<string, unknown> }
97
+ })
98
+
99
+ const results: LookupResult[] = refs.map((ref: any, index) => ({
100
+ table: String(ref?.table ?? ''),
101
+ key: (parsed[index]?.key ?? {}) as Record<string, unknown>,
102
+ row: null,
103
+ }))
104
+
105
+ for (const { table, refs: indexes } of byTable.values()) {
106
+ const cols = table.identity.cols
107
+ const params: unknown[] = []
108
+ const groups: string[] = []
109
+ const wanted = new Map<string, number[]>()
110
+
111
+ for (const index of indexes) {
112
+ const key = parsed[index]!.key
113
+ // A ref whose key does not name exactly the identity is skipped
114
+ // rather than widened — a partial key is a predicate over more than
115
+ // one row, which is the bug `validateKey` refuses for a write and
116
+ // there is no reason to accept it for a read.
117
+ if (cols.some(col => !(col in key))) continue
118
+ groups.push(
119
+ `(${cols
120
+ .map(col => {
121
+ params.push(key[col])
122
+ return `${qId(col)} = ?`
123
+ })
124
+ .join(' AND ')})`,
125
+ )
126
+ const print = fingerprint(cols, key)
127
+ wanted.set(print, [...(wanted.get(print) ?? []), index])
128
+ }
129
+ if (!groups.length) continue
130
+
131
+ const rows = (await connection
132
+ .query(
133
+ `SELECT * FROM ${qId(table.name)} WHERE ${groups.join(' OR ')}`,
134
+ )
135
+ .all(...params)) as Record<string, unknown>[]
136
+
137
+ for (const row of rows) {
138
+ for (const index of wanted.get(fingerprint(cols, row)) ?? []) {
139
+ results[index]!.row = row
140
+ }
141
+ }
142
+ }
143
+
144
+ return response.json.success('success', { rows: results })
145
+ },
146
+ (error: any) => response.json.error(400, error?.message ?? 'Lookup failed'),
147
+ )
148
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * CSV import — the rows, not the file.
3
+ *
4
+ * The parse happens in the browser, in `shared/csv.ts`, and stays there. This
5
+ * endpoint takes rows that are already records, so the mapping the user
6
+ * confirmed in the dialog is what arrives, rather than a file the server
7
+ * re-guesses the columns of. No CSV text reaches the server at all.
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
+ }