@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
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.6",
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.6",
37
+ "@bakery-framework/orm": "^2.0.0-alpha.6"
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,261 @@
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
+ const KEY_STORAGE = '__db_key'
34
+ const KEY_PARAM = 'db-key'
35
+
36
+ /**
37
+ * Take a `?db-key=` out of the address bar and keep it for the session.
38
+ *
39
+ * A human opening a link cannot set a header, so the credential arrives in the
40
+ * URL; leaving it there puts it in history, in the referrer of every outbound
41
+ * link, and in a screenshot. It is moved to `sessionStorage` and the URL is
42
+ * rewritten before anything else runs.
43
+ */
44
+ export function adoptUrlKey(): void {
45
+ const url = new URL(location.href)
46
+ const key = url.searchParams.get(KEY_PARAM)
47
+ if (!key) return
48
+ sessionStorage.setItem(KEY_STORAGE, key)
49
+ url.searchParams.delete(KEY_PARAM)
50
+ history.replaceState(null, '', url)
51
+ }
52
+
53
+ /**
54
+ * The credential as a **header**.
55
+ *
56
+ * Never as a query parameter on a write: `access.ts` refuses a URL credential
57
+ * on any non-safe method, because a link is something a cross-site page can
58
+ * make the browser follow and `checkCsrf` passes when `Origin` is absent.
59
+ */
60
+ function keyHeaders(): Record<string, string> {
61
+ const key = sessionStorage.getItem(KEY_STORAGE)
62
+ return key ? { 'x-db-key': key } : {}
63
+ }
64
+
65
+ interface Envelope {
66
+ status?: number
67
+ message?: string
68
+ data?: unknown
69
+ /** Milliseconds the server spent, filled in by `router.ts`. */
70
+ time?: number
71
+ }
72
+
73
+ /**
74
+ * The data, plus what the server said it cost.
75
+ *
76
+ * `time` is the envelope's own field — `router.ts` fills it from `getElapsed`,
77
+ * so it is the server's measurement of its own work rather than a round trip
78
+ * timed in the browser. The status bar shows it, which is the one honest number
79
+ * available for "why is this slow": a filter that cannot use an index shows up
80
+ * here immediately.
81
+ */
82
+ export interface Timed<T> {
83
+ data: T
84
+ ms: number
85
+ }
86
+
87
+ async function unwrapEnvelope<T>(res: Response): Promise<Timed<T>> {
88
+ const body = (await res.json().catch(() => null)) as Envelope | null
89
+ const status = body?.status ?? res.status
90
+ if (!body || status < 200 || status >= 300) {
91
+ throw new ApiError(
92
+ body?.message || `Request failed (${status})`,
93
+ status,
94
+ body?.data,
95
+ )
96
+ }
97
+ return { data: body.data as T, ms: body.time ?? 0 }
98
+ }
99
+
100
+ async function unwrap<T>(res: Response): Promise<T> {
101
+ return (await unwrapEnvelope<T>(res)).data
102
+ }
103
+
104
+ export async function apiGet<T>(
105
+ path: string,
106
+ params?: Record<string, string>,
107
+ signal?: AbortSignal,
108
+ ): Promise<T> {
109
+ const query = params ? `?${new URLSearchParams(params)}` : ''
110
+ const res = await fetch(`/api/_db/${path}${query}`, {
111
+ headers: keyHeaders(),
112
+ signal,
113
+ })
114
+ return await unwrap<T>(res)
115
+ }
116
+
117
+ export async function apiSend<T>(
118
+ method: 'POST' | 'PATCH' | 'DELETE',
119
+ path: string,
120
+ body: unknown,
121
+ signal?: AbortSignal,
122
+ ): Promise<T> {
123
+ const res = await fetch(`/api/_db/${path}`, {
124
+ method,
125
+ headers: { ...keyHeaders(), 'content-type': 'application/json' },
126
+ body: JSON.stringify(body),
127
+ signal,
128
+ })
129
+ return await unwrap<T>(res)
130
+ }
131
+
132
+ export async function fetchSchema(): Promise<SchemaReport> {
133
+ return await apiGet<SchemaReport>('schema')
134
+ }
135
+
136
+ export async function fetchGraph(): Promise<SchemaGraph> {
137
+ return await apiGet<SchemaGraph>('graph')
138
+ }
139
+
140
+ /**
141
+ * One page of a table, with the server's own timing.
142
+ *
143
+ * **`filters` carries an operator now.** It used to be a bare column→substring
144
+ * record, which meant `col LIKE '%value%'` and nothing else — so a filter could
145
+ * narrow a page but could never *name* a row, `1` matching `11` and `21`. With
146
+ * `eq` in the vocabulary it can, which is what removed `ViewState.focus` and
147
+ * turned a foreign-key jump into an ordinary filter.
148
+ *
149
+ * The scalar form is still what the endpoint accepts from anyone else; `toWire`
150
+ * always sends the object form from here.
151
+ */
152
+ export async function fetchPage(view: ViewState): Promise<Timed<TablePage>> {
153
+ const params: Record<string, string> = {
154
+ tableName: view.table,
155
+ page: String(view.page),
156
+ pageSize: String(PAGE_SIZE),
157
+ sortOrder: view.sortOrder,
158
+ }
159
+ if (view.sortBy) params.sortBy = view.sortBy
160
+ const wire = toWire(view.filters)
161
+ if (Object.keys(wire).length) params.filters = JSON.stringify(wire)
162
+
163
+ const query = `?${new URLSearchParams(params)}`
164
+ const res = await fetch(`/api/_db/table-data${query}`, {
165
+ headers: keyHeaders(),
166
+ })
167
+ return await unwrapEnvelope<TablePage>(res)
168
+ }
169
+
170
+ export interface LookupRef {
171
+ table: string
172
+ key: Record<string, unknown>
173
+ }
174
+
175
+ export interface LookupResult {
176
+ table: string
177
+ key: Record<string, unknown>
178
+ row: Record<string, unknown> | null
179
+ }
180
+
181
+ /** Bounded at 200 by `policy.ts`; `fk.ts` never sends more than a page's worth. */
182
+ export async function lookupRefs(
183
+ refs: LookupRef[],
184
+ signal?: AbortSignal,
185
+ ): Promise<LookupResult[]> {
186
+ const data = await apiSend<{ rows: LookupResult[] }>(
187
+ 'POST',
188
+ 'lookup',
189
+ { refs },
190
+ signal,
191
+ )
192
+ return data.rows ?? []
193
+ }
194
+
195
+ export interface UpdateResult {
196
+ changed: number
197
+ row: Record<string, unknown> | null
198
+ }
199
+
200
+ export async function patchRow(payload: {
201
+ table: string
202
+ key: Record<string, unknown>
203
+ set: Record<string, unknown>
204
+ expect: Record<string, unknown>
205
+ force?: boolean
206
+ }): Promise<UpdateResult> {
207
+ return await apiSend<UpdateResult>('PATCH', 'row', payload)
208
+ }
209
+
210
+ export interface BulkResult {
211
+ changed: number
212
+ conflicts: { index: number; key: Record<string, unknown>; reason: string }[]
213
+ }
214
+
215
+ export async function bulkEdit(payload: {
216
+ table: string
217
+ edits: { key: Record<string, unknown>; set: Record<string, unknown> }[]
218
+ dryRun?: boolean
219
+ }): Promise<BulkResult> {
220
+ return await apiSend<BulkResult>('POST', 'rows/bulk', payload)
221
+ }
222
+
223
+ export interface DeleteResult {
224
+ deleted: number
225
+ conflicts: { index: number; key: Record<string, unknown>; reason: string }[]
226
+ }
227
+
228
+ export async function deleteRows(payload: {
229
+ table: string
230
+ keys: Record<string, unknown>[]
231
+ dryRun?: boolean
232
+ }): Promise<DeleteResult> {
233
+ return await apiSend<DeleteResult>('DELETE', 'rows', payload)
234
+ }
235
+
236
+ export interface InsertResult {
237
+ inserted: number
238
+ rows?: Record<string, unknown>[]
239
+ }
240
+
241
+ export async function insertRows(payload: {
242
+ table: string
243
+ rows: Record<string, unknown>[]
244
+ }): Promise<InsertResult> {
245
+ return await apiSend<InsertResult>('POST', 'rows', payload)
246
+ }
247
+
248
+ export interface ImportResult {
249
+ inserted: number
250
+ skipped: number
251
+ errors: { row: number; column: string; code: string; message: string }[]
252
+ }
253
+
254
+ export async function importRows(payload: {
255
+ table: string
256
+ rows: Record<string, unknown>[]
257
+ onBadRow: 'stop' | 'skip'
258
+ dryRun?: boolean
259
+ }): Promise<ImportResult> {
260
+ return await apiSend<ImportResult>('POST', 'import', payload)
261
+ }