@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.
- package/package.json +4 -4
- package/src/access.ts +188 -0
- package/src/client/api.ts +279 -0
- package/src/client/bulk.ts +357 -0
- package/src/client/cell.ts +139 -0
- package/src/client/confirm.ts +203 -0
- package/src/client/csv-commit.ts +185 -0
- package/src/client/csv-map.ts +274 -0
- package/src/client/csv-model.ts +420 -0
- package/src/client/csv-pick.ts +54 -0
- package/src/client/csv-preview.ts +89 -0
- package/src/client/csv.ts +104 -0
- package/src/client/dom.ts +164 -0
- package/src/client/edit-session.ts +219 -0
- package/src/client/editors.ts +283 -0
- package/src/client/filter-builder.ts +198 -0
- package/src/client/fk.ts +269 -0
- package/src/client/grid-body.ts +106 -0
- package/src/client/grid-header.ts +65 -0
- package/src/client/grid-rowbar.ts +64 -0
- package/src/client/grid.ts +468 -0
- package/src/client/meta.ts +185 -0
- package/src/client/page.ts +332 -0
- package/src/client/panel.ts +296 -0
- package/src/client/relations.ts +205 -0
- package/src/client/save.ts +205 -0
- package/src/client/sidebar.ts +110 -0
- package/src/client/state.ts +218 -0
- package/src/client/statusbar.ts +130 -0
- package/src/client/structure.ts +234 -0
- package/src/client/tabs.ts +219 -0
- package/src/client/tabstrip.ts +127 -0
- package/src/client.ts +376 -160
- package/src/endpoints/common.ts +122 -0
- package/src/endpoints/graph.ts +148 -0
- package/src/endpoints/import.ts +89 -0
- package/src/endpoints/read.ts +173 -0
- package/src/endpoints/rows.ts +435 -0
- package/src/identity.ts +391 -0
- package/src/index.ts +40 -45
- package/src/policy.ts +45 -0
- package/src/preview.ts +53 -0
- package/src/setup.ts +64 -81
- package/src/shared/coerce.ts +399 -0
- package/src/shared/csv.ts +186 -0
- package/src/shared/filters.ts +200 -0
- package/src/shared/plan.ts +173 -0
- package/src/shell.ts +187 -0
- package/src/validate.ts +295 -0
- package/src/credential.ts +0 -26
- 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
|
+
}
|