@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
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@bakery-framework/plugin-db-explorer",
3
- "version": "2.0.0-alpha.5",
4
- "description": "Bakery database explorer plugin — read-only browsing of the app database.",
3
+ "version": "2.0.0-alpha.7",
4
+ "description": "Bakery database explorer plugin — browse and edit rows no raw SQL, no DDL.",
5
5
  "keywords": [
6
6
  "bakery",
7
7
  "bun",
@@ -33,8 +33,8 @@
33
33
  "!src/tests"
34
34
  ],
35
35
  "dependencies": {
36
- "@bakery-framework/core": "^2.0.0-alpha.5",
37
- "@bakery-framework/orm": "^2.0.0-alpha.5"
36
+ "@bakery-framework/core": "^2.0.0-alpha.7",
37
+ "@bakery-framework/orm": "^2.0.0-alpha.7"
38
38
  },
39
39
  "engines": {
40
40
  "bun": ">=1.3.14"
package/src/access.ts ADDED
@@ -0,0 +1,188 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks'
2
+ import {
3
+ credentialMatches,
4
+ readCredential,
5
+ } from '@bakery-framework/core/utils/http'
6
+
7
+ /**
8
+ * Who may do what in the explorer.
9
+ *
10
+ * The explorer used to be read-only by construction, so a boolean answered the
11
+ * whole question. It edits rows now, and "may this request in" and "may this
12
+ * request *write*" are different questions — so the predicate returns a level
13
+ * rather than a yes.
14
+ *
15
+ * Two doors, and an application may use either or both:
16
+ *
17
+ * - `users` — named credentials, for people and scripts that have no session
18
+ * (an on-call engineer with a key, a seeding job).
19
+ * - `authorize` — a predicate over the request, for applications that already
20
+ * know who their users are and would rather not keep a second list.
21
+ *
22
+ * Either can admit and the **higher** level wins, because they answer about the
23
+ * same caller: a session admin presenting a read-only key is still an admin.
24
+ *
25
+ * Everything fails closed. Nothing configured admits nobody — the same default
26
+ * the read-only explorer had, and the reason there is no `writes: true` flag to
27
+ * leave set by accident.
28
+ *
29
+ * This deliberately does not reuse core's `isAuthorized`, which is boolean and
30
+ * stays correct for the dashboard and analytics. It copies its discipline
31
+ * instead: an exact match, never a truthy one, and a throw is a denial.
32
+ */
33
+
34
+ /** What a caller may do. Ordered: `write` implies `read`. */
35
+ export type Access = 'read' | 'write'
36
+
37
+ /**
38
+ * An application's own access check. Return `'write'`, `'read'`, or `false`.
39
+ *
40
+ * Anything else — including `true` — is a denial. The predicate is application
41
+ * code and this return type is only advice; an untyped or transpiled one can
42
+ * hand back anything, and admission is the expensive direction to get wrong.
43
+ */
44
+ export type AccessFn = (
45
+ req: Request,
46
+ ) => Access | false | Promise<Access | false>
47
+
48
+ export interface ExplorerUser {
49
+ /** Presented as `x-db-key`, a Bearer token, or `?db-key=` on a read. */
50
+ credential: string
51
+ access: Access
52
+ }
53
+
54
+ /** Named because a log line saying which key was used beats one saying "a key". */
55
+ export type ExplorerUsers = Record<string, ExplorerUser>
56
+
57
+ const DB_KEY = 'db-key'
58
+
59
+ /** Methods that cannot change state, and may therefore carry a URL credential. */
60
+ const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS'])
61
+
62
+ const RANK: Record<Access, number> = { read: 1, write: 2 }
63
+
64
+ /** The higher of two grants, or whichever is present. */
65
+ function higher(a: Access | false, b: Access | false): Access | false {
66
+ if (!a) return b
67
+ if (!b) return a
68
+ return RANK[a] >= RANK[b] ? a : b
69
+ }
70
+
71
+ /**
72
+ * The credential this request presents, or `null`.
73
+ *
74
+ * Core's `readCredential` accepts three forms — `x-db-key`, a Bearer token, and
75
+ * `?db-key=` — and the query form is deliberate there: a human opening a URL
76
+ * cannot set a header.
77
+ *
78
+ * **On a state-changing request the query form is refused.** A credential in a
79
+ * URL is what makes a cross-site write possible: the browser will send it
80
+ * because it is in the link, and `checkCsrf` — which is an `Origin` check, not
81
+ * a token — passes when `Origin` is absent or literally `"null"`, as it is from
82
+ * a sandboxed iframe or some redirect chains. Requiring a header for writes
83
+ * means the caller had to run script on this origin.
84
+ */
85
+ function presentedKey(req: Request): string | null {
86
+ const presented = readCredential(req, DB_KEY)
87
+ if (presented === null) return null
88
+ if (SAFE_METHODS.has(req.method)) return presented
89
+
90
+ // `readCredential` prefers header, then Bearer, then the query — so if
91
+ // neither header is present, the value it returned came from the URL.
92
+ const fromHeader =
93
+ req.headers.has(`x-${DB_KEY}`) || req.headers.has('authorization')
94
+ return fromHeader ? presented : null
95
+ }
96
+
97
+ /**
98
+ * The best level any configured user grants this request.
99
+ *
100
+ * **Every entry is compared, with no early exit.** Each comparison is constant
101
+ * time (core's `credentialMatches`), and stopping at the first match would make
102
+ * the response time depend on where in the map the matching key sits — which
103
+ * leaks the position of a valid key over enough samples.
104
+ */
105
+ export function accessFromUsers(
106
+ req: Request,
107
+ users: ExplorerUsers | undefined,
108
+ ): Access | false {
109
+ if (!users) return false
110
+
111
+ const presented = presentedKey(req)
112
+ if (!presented) return false
113
+
114
+ let granted: Access | false = false
115
+ for (const user of Object.values(users)) {
116
+ if (credentialMatches(user.credential, presented)) {
117
+ granted = higher(granted, user.access)
118
+ }
119
+ }
120
+ return granted
121
+ }
122
+
123
+ /** The predicate's answer, with anything that is not an exact level denied. */
124
+ export async function accessFromPredicate(
125
+ req: Request,
126
+ authorize: AccessFn | undefined,
127
+ ): Promise<Access | false> {
128
+ if (!authorize) return false
129
+
130
+ try {
131
+ const answer = await authorize(req)
132
+ // `=== `, never truthiness. `true` is a denial here on purpose: a predicate
133
+ // written against the old boolean API means "let them in" and cannot mean
134
+ // "let them write", and guessing which is exactly the mistake this type
135
+ // exists to prevent.
136
+ return answer === 'write' || answer === 'read' ? answer : false
137
+ } catch {
138
+ // An access check that errors is indeterminate, and indeterminate is a
139
+ // denial (convention 2).
140
+ return false
141
+ }
142
+ }
143
+
144
+ export interface AccessConfig {
145
+ users?: ExplorerUsers
146
+ authorize?: AccessFn
147
+ }
148
+
149
+ /** The caller's level: the better of the two doors, `false` if neither admits. */
150
+ export async function resolveAccess(
151
+ req: Request,
152
+ config: AccessConfig,
153
+ ): Promise<Access | false> {
154
+ // Both doors are consulted even when the first admits — the predicate may
155
+ // grant `write` where a key granted `read`, and the caller is one person.
156
+ const fromKey = accessFromUsers(req, config.users)
157
+ const fromPredicate = await accessFromPredicate(req, config.authorize)
158
+ return higher(fromKey, fromPredicate)
159
+ }
160
+
161
+ /** Whether `access` permits a write. Spelled once so no call site guesses. */
162
+ export function canWrite(access: Access | false): boolean {
163
+ return access === 'write'
164
+ }
165
+
166
+ /**
167
+ * The current request's access level, for endpoints.
168
+ *
169
+ * `AsyncLocalStorage` rather than a module variable, for the same reason
170
+ * `hostStore` is one in core: a module variable is shared by every in-flight
171
+ * request, so under any concurrency at all one caller's level would decide
172
+ * another caller's write. It is also cheaper than re-resolving per endpoint,
173
+ * which would run an application's `authorize` predicate — possibly a session
174
+ * lookup — twice for one request.
175
+ *
176
+ * Outside a request it is `false`, which is the safe answer rather than a
177
+ * crash: an endpoint reached some other way has no caller to grant anything.
178
+ */
179
+ export const accessStore = new AsyncLocalStorage<Access>()
180
+
181
+ export function currentAccess(): Access | false {
182
+ return accessStore.getStore() ?? false
183
+ }
184
+
185
+ /** Whether the *current* request may write. The check every write endpoint makes. */
186
+ export function currentCanWrite(): boolean {
187
+ return canWrite(currentAccess())
188
+ }
@@ -0,0 +1,279 @@
1
+ /**
2
+ * Every request this client makes, and nothing that renders.
3
+ *
4
+ * The rule that keeps the grid's complexity down is mechanical: **no function
5
+ * both fetches and renders.** These return data or throw `ApiError`; the DOM
6
+ * modules take data and return nodes. The old `renderTable` did both and scored
7
+ * 34 against a maximum of 25 with a fraction of this feature set.
8
+ */
9
+
10
+ import { toWire } from '../shared/filters'
11
+ import type { SchemaGraph, SchemaReport, TablePage } from './meta'
12
+ import type { ViewState } from './state'
13
+ import { PAGE_SIZE } from './state'
14
+
15
+ /**
16
+ * A failed call, with the server's own envelope attached.
17
+ *
18
+ * `data` matters as much as the status: a 409 from `PATCH /api/_db/row` carries
19
+ * the row as it now stands, which is what makes *Keep mine / Take theirs*
20
+ * possible rather than "please try again".
21
+ */
22
+ export class ApiError extends Error {
23
+ constructor(
24
+ message: string,
25
+ readonly status: number,
26
+ readonly data: unknown,
27
+ ) {
28
+ super(message)
29
+ this.name = 'ApiError'
30
+ }
31
+ }
32
+
33
+ /**
34
+ * What to show a user when a call threw.
35
+ *
36
+ * Here rather than in `dom.ts` because the thing it knows about is `ApiError`,
37
+ * whose `message` is the server's own sentence from the envelope — the reason
38
+ * a failed write reads as "row was modified by someone else" instead of
39
+ * "Request failed (409)". `String(error)` is the fallback for the two throws
40
+ * that are not ours: an abort and a network failure.
41
+ *
42
+ * There used to be three of these — one exported from `bulk.ts`, a private
43
+ * near-copy in `save.ts`, and a third inlined at the `notify` call in
44
+ * `csv-commit.ts` — so improving the wording in one left the other two alone.
45
+ */
46
+ export function messageOf(error: unknown): string {
47
+ const api = error as Partial<ApiError>
48
+ return api?.message ?? String(error)
49
+ }
50
+
51
+ const KEY_STORAGE = '__db_key'
52
+ const KEY_PARAM = 'db-key'
53
+
54
+ /**
55
+ * Take a `?db-key=` out of the address bar and keep it for the session.
56
+ *
57
+ * A human opening a link cannot set a header, so the credential arrives in the
58
+ * URL; leaving it there puts it in history, in the referrer of every outbound
59
+ * link, and in a screenshot. It is moved to `sessionStorage` and the URL is
60
+ * rewritten before anything else runs.
61
+ */
62
+ export function adoptUrlKey(): void {
63
+ const url = new URL(location.href)
64
+ const key = url.searchParams.get(KEY_PARAM)
65
+ if (!key) return
66
+ sessionStorage.setItem(KEY_STORAGE, key)
67
+ url.searchParams.delete(KEY_PARAM)
68
+ history.replaceState(null, '', url)
69
+ }
70
+
71
+ /**
72
+ * The credential as a **header**.
73
+ *
74
+ * Never as a query parameter on a write: `access.ts` refuses a URL credential
75
+ * on any non-safe method, because a link is something a cross-site page can
76
+ * make the browser follow and `checkCsrf` passes when `Origin` is absent.
77
+ */
78
+ function keyHeaders(): Record<string, string> {
79
+ const key = sessionStorage.getItem(KEY_STORAGE)
80
+ return key ? { 'x-db-key': key } : {}
81
+ }
82
+
83
+ interface Envelope {
84
+ status?: number
85
+ message?: string
86
+ data?: unknown
87
+ /** Milliseconds the server spent, filled in by `router.ts`. */
88
+ time?: number
89
+ }
90
+
91
+ /**
92
+ * The data, plus what the server said it cost.
93
+ *
94
+ * `time` is the envelope's own field — `router.ts` fills it from `getElapsed`,
95
+ * so it is the server's measurement of its own work rather than a round trip
96
+ * timed in the browser. The status bar shows it, which is the one honest number
97
+ * available for "why is this slow": a filter that cannot use an index shows up
98
+ * here immediately.
99
+ */
100
+ interface Timed<T> {
101
+ data: T
102
+ ms: number
103
+ }
104
+
105
+ async function unwrapEnvelope<T>(res: Response): Promise<Timed<T>> {
106
+ const body = (await res.json().catch(() => null)) as Envelope | null
107
+ const status = body?.status ?? res.status
108
+ if (!body || status < 200 || status >= 300) {
109
+ throw new ApiError(
110
+ body?.message || `Request failed (${status})`,
111
+ status,
112
+ body?.data,
113
+ )
114
+ }
115
+ return { data: body.data as T, ms: body.time ?? 0 }
116
+ }
117
+
118
+ async function unwrap<T>(res: Response): Promise<T> {
119
+ return (await unwrapEnvelope<T>(res)).data
120
+ }
121
+
122
+ async function apiGet<T>(
123
+ path: string,
124
+ params?: Record<string, string>,
125
+ signal?: AbortSignal,
126
+ ): Promise<T> {
127
+ const query = params ? `?${new URLSearchParams(params)}` : ''
128
+ const res = await fetch(`/api/_db/${path}${query}`, {
129
+ headers: keyHeaders(),
130
+ signal,
131
+ })
132
+ return await unwrap<T>(res)
133
+ }
134
+
135
+ async function apiSend<T>(
136
+ method: 'POST' | 'PATCH' | 'DELETE',
137
+ path: string,
138
+ body: unknown,
139
+ signal?: AbortSignal,
140
+ ): Promise<T> {
141
+ const res = await fetch(`/api/_db/${path}`, {
142
+ method,
143
+ headers: { ...keyHeaders(), 'content-type': 'application/json' },
144
+ body: JSON.stringify(body),
145
+ signal,
146
+ })
147
+ return await unwrap<T>(res)
148
+ }
149
+
150
+ export async function fetchSchema(): Promise<SchemaReport> {
151
+ return await apiGet<SchemaReport>('schema')
152
+ }
153
+
154
+ export async function fetchGraph(): Promise<SchemaGraph> {
155
+ return await apiGet<SchemaGraph>('graph')
156
+ }
157
+
158
+ /**
159
+ * One page of a table, with the server's own timing.
160
+ *
161
+ * **`filters` carries an operator now.** It used to be a bare column→substring
162
+ * record, which meant `col LIKE '%value%'` and nothing else — so a filter could
163
+ * narrow a page but could never *name* a row, `1` matching `11` and `21`. With
164
+ * `eq` in the vocabulary it can, which is what removed `ViewState.focus` and
165
+ * turned a foreign-key jump into an ordinary filter.
166
+ *
167
+ * The scalar form is still what the endpoint accepts from anyone else; `toWire`
168
+ * always sends the object form from here.
169
+ */
170
+ export async function fetchPage(view: ViewState): Promise<Timed<TablePage>> {
171
+ const params: Record<string, string> = {
172
+ tableName: view.table,
173
+ page: String(view.page),
174
+ pageSize: String(PAGE_SIZE),
175
+ sortOrder: view.sortOrder,
176
+ }
177
+ if (view.sortBy) params.sortBy = view.sortBy
178
+ const wire = toWire(view.filters)
179
+ if (Object.keys(wire).length) params.filters = JSON.stringify(wire)
180
+
181
+ const query = `?${new URLSearchParams(params)}`
182
+ const res = await fetch(`/api/_db/table-data${query}`, {
183
+ headers: keyHeaders(),
184
+ })
185
+ return await unwrapEnvelope<TablePage>(res)
186
+ }
187
+
188
+ export interface LookupRef {
189
+ table: string
190
+ key: Record<string, unknown>
191
+ }
192
+
193
+ export interface LookupResult {
194
+ table: string
195
+ key: Record<string, unknown>
196
+ row: Record<string, unknown> | null
197
+ }
198
+
199
+ /** Bounded at 200 by `policy.ts`; `fk.ts` never sends more than a page's worth. */
200
+ export async function lookupRefs(
201
+ refs: LookupRef[],
202
+ signal?: AbortSignal,
203
+ ): Promise<LookupResult[]> {
204
+ const data = await apiSend<{ rows: LookupResult[] }>(
205
+ 'POST',
206
+ 'lookup',
207
+ { refs },
208
+ signal,
209
+ )
210
+ return data.rows ?? []
211
+ }
212
+
213
+ interface UpdateResult {
214
+ changed: number
215
+ row: Record<string, unknown> | null
216
+ }
217
+
218
+ export async function patchRow(payload: {
219
+ table: string
220
+ key: Record<string, unknown>
221
+ set: Record<string, unknown>
222
+ expect: Record<string, unknown>
223
+ force?: boolean
224
+ }): Promise<UpdateResult> {
225
+ return await apiSend<UpdateResult>('PATCH', 'row', payload)
226
+ }
227
+
228
+ interface BulkResult {
229
+ changed: number
230
+ conflicts: { index: number; key: Record<string, unknown>; reason: string }[]
231
+ }
232
+
233
+ export async function bulkEdit(payload: {
234
+ table: string
235
+ edits: { key: Record<string, unknown>; set: Record<string, unknown> }[]
236
+ dryRun?: boolean
237
+ }): Promise<BulkResult> {
238
+ return await apiSend<BulkResult>('POST', 'rows/bulk', payload)
239
+ }
240
+
241
+ interface DeleteResult {
242
+ deleted: number
243
+ conflicts: { index: number; key: Record<string, unknown>; reason: string }[]
244
+ }
245
+
246
+ export async function deleteRows(payload: {
247
+ table: string
248
+ keys: Record<string, unknown>[]
249
+ dryRun?: boolean
250
+ }): Promise<DeleteResult> {
251
+ return await apiSend<DeleteResult>('DELETE', 'rows', payload)
252
+ }
253
+
254
+ interface InsertResult {
255
+ inserted: number
256
+ rows?: Record<string, unknown>[]
257
+ }
258
+
259
+ export async function insertRows(payload: {
260
+ table: string
261
+ rows: Record<string, unknown>[]
262
+ }): Promise<InsertResult> {
263
+ return await apiSend<InsertResult>('POST', 'rows', payload)
264
+ }
265
+
266
+ export interface ImportResult {
267
+ inserted: number
268
+ skipped: number
269
+ errors: { row: number; column: string; code: string; message: string }[]
270
+ }
271
+
272
+ export async function importRows(payload: {
273
+ table: string
274
+ rows: Record<string, unknown>[]
275
+ onBadRow: 'stop' | 'skip'
276
+ dryRun?: boolean
277
+ }): Promise<ImportResult> {
278
+ return await apiSend<ImportResult>('POST', 'import', payload)
279
+ }