@bakery-framework/plugin-db-explorer 2.0.0-alpha.4 → 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 +42 -42
  41. package/src/policy.ts +45 -0
  42. package/src/preview.ts +53 -0
  43. package/src/setup.ts +63 -80
  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/authorize.ts +0 -82
  51. package/src/endpoints.ts +0 -48
package/src/shell.ts ADDED
@@ -0,0 +1,187 @@
1
+ /**
2
+ * The HTML document the explorer boots into, and all of its CSS.
3
+ *
4
+ * Extracted from `setup.ts` when the client became a data editor: the sheet
5
+ * grew from twenty rules to a hundred and thirty, and a route table with a
6
+ * stylesheet in the middle of it is a route table nobody reads. `setup.ts`
7
+ * keeps the routing and the CSRF policy; this keeps the paint.
8
+ *
9
+ * There is **no markup generated from data here** — the body is an empty
10
+ * `#app` and a module script. Every value the user sees is written by
11
+ * `client/dom.ts` through `textContent`, which is why this plugin has no XSS
12
+ * surface even though it renders arbitrary row contents.
13
+ */
14
+
15
+ const CSS = `
16
+ :root {
17
+ color-scheme: dark;
18
+ --bg: #0f1115; --panel: #151922; --line: #262b36; --line-soft: #1e2430;
19
+ --text: #e6e8ee; --dim: #9aa3b2; --faint: #5b6472;
20
+ --accent: #2f6feb; --accent-bg: #16233d; --accent-text: #cfe0ff;
21
+ --bad: #ff8ba0; --bad-bg: #2b1620; --good: #7ee2a8;
22
+ }
23
+ * { box-sizing: border-box; }
24
+ body { margin: 0; font: 14px/1.5 ui-sans-serif, system-ui, sans-serif; background: var(--bg); color: var(--text); }
25
+ #app { display: flex; min-height: 100vh; }
26
+
27
+ .side-slot { flex-shrink: 0; }
28
+ .side { width: 230px; padding: 1rem; border-right: 1px solid var(--line); height: 100vh; overflow-y: auto; display: flex; flex-direction: column; gap: .3rem; }
29
+ .table-list { flex: 1; min-height: 0; overflow-y: auto; }
30
+ .system-toggle { border-top: 1px solid var(--line); padding-top: .5rem; cursor: pointer; display: block; }
31
+ .brand { font-size: 1rem; margin: 0 0 .2rem; }
32
+ .note { color: var(--dim); font-size: .8rem; }
33
+ .error, .row-error { color: var(--bad); }
34
+ .error { padding: 1rem; }
35
+ .table-btn { display: block; width: 100%; text-align: left; background: none; border: 0; color: #cfd6e4; padding: .35rem .5rem; border-radius: 6px; cursor: pointer; font: inherit; }
36
+ .table-btn:hover { background: #1a1f2b; }
37
+ .table-btn.active { background: var(--accent-bg); color: var(--accent-text); }
38
+ .table-btn .ro { color: var(--faint); float: right; }
39
+
40
+ /* The right-hand column: tab strip, the active view, then the status bar. */
41
+ .column { flex: 1; min-width: 0; display: flex; flex-direction: column; height: 100vh; }
42
+ .main { flex: 1; padding: 1rem 1.5rem; min-width: 0; overflow-y: auto; }
43
+
44
+ /* Table tabs. Italic is the preview state, borrowed from VS Code. */
45
+ .tabstrip { display: flex; align-items: stretch; gap: 2px; border-bottom: 1px solid var(--line); background: var(--panel); overflow-x: auto; }
46
+ .tab { display: flex; align-items: center; border-right: 1px solid var(--line); background: transparent; max-width: 16rem; }
47
+ .tab.active { background: var(--bg); box-shadow: inset 0 2px 0 var(--accent); }
48
+ .tab-label { font: inherit; font-family: ui-monospace, monospace; font-size: .8rem; background: none; border: 0; color: var(--dim); padding: .45rem .3rem .45rem .8rem; cursor: pointer; max-width: 13rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
49
+ .tab.active .tab-label { color: var(--text); }
50
+ .tab.preview .tab-label { font-style: italic; }
51
+ .tab-close { font: inherit; background: none; border: 0; color: var(--faint); cursor: pointer; padding: .2rem .5rem .2rem .2rem; border-radius: 4px; }
52
+ .tab-close:hover { color: var(--bad); background: var(--bad-bg); }
53
+ .tab-new { font: inherit; background: none; border: 0; color: var(--dim); cursor: pointer; padding: .3rem .8rem; }
54
+ .tab-new:hover { color: var(--accent-text); }
55
+
56
+ /* Data / Structure / Relations — one level of nesting, and only one. */
57
+ .viewtabs { display: flex; gap: .2rem; padding: .3rem .8rem 0; border-bottom: 1px solid var(--line); background: var(--bg); }
58
+ .viewtab { font: inherit; font-size: .8rem; background: none; border: 0; border-bottom: 2px solid transparent; color: var(--dim); padding: .3rem .7rem; cursor: pointer; }
59
+ .viewtab:hover { color: var(--text); }
60
+ .viewtab.active { color: var(--accent-text); border-bottom-color: var(--accent); }
61
+ .newtab { padding: 2rem; }
62
+
63
+ .statusbar { border-top: 1px solid var(--line); background: var(--panel); color: var(--dim); font-size: .75rem; padding: .3rem 1rem; flex-shrink: 0; font-family: ui-monospace, monospace; }
64
+ .statusbar.dirty { color: var(--bad); }
65
+
66
+ /* Structure and Relations. */
67
+ .structure, .relations { display: flex; flex-direction: column; gap: 1.2rem; }
68
+ .structure-section h3 { margin: 0 0 .3rem; font-size: .9rem; }
69
+ .structure-grid td { white-space: normal; }
70
+ .relation-list { display: flex; flex-direction: column; gap: .3rem; margin-top: .4rem; }
71
+ .relation { display: flex; gap: .5rem; align-items: center; flex-wrap: wrap; }
72
+ .relation-cols { font-family: ui-monospace, monospace; font-size: .8rem; color: var(--dim); }
73
+
74
+ /* The filter builder: a chip per condition. */
75
+ .filter-bar { display: flex; gap: .4rem; flex-wrap: wrap; align-items: center; margin-bottom: .6rem; }
76
+ .filter-chip { display: flex; gap: .2rem; align-items: center; background: var(--panel); border: 1px solid var(--line); border-radius: 999px; padding: .15rem .3rem .15rem .5rem; }
77
+ .filter-chip .sel { border: 0; background: transparent; font-size: .78rem; }
78
+ .filter-value { font: inherit; font-size: .78rem; background: var(--bg); color: var(--text); border: 1px solid var(--line); border-radius: 4px; padding: .1rem .35rem; width: 8rem; }
79
+ .filter-drop { border: 0; background: none; color: var(--faint); padding: 0 .4rem; }
80
+ .filter-drop:hover { color: var(--bad); }
81
+ .filter-add { font-size: .78rem; }
82
+ .filter-clash { color: var(--bad); font-size: .75rem; }
83
+
84
+ .table-head { display: flex; align-items: baseline; gap: 1rem; flex-wrap: wrap; }
85
+ .table-head h2 { margin: .2rem 0 .8rem; font-family: ui-monospace, monospace; font-size: 1rem; }
86
+ .banner { border: 1px solid var(--line); border-left: 3px solid var(--accent); background: var(--panel); padding: .5rem .8rem; border-radius: 6px; margin: 0 0 .8rem; }
87
+ .banner.warn { border-left-color: var(--bad); }
88
+ .crumbs { display: flex; gap: .4rem; align-items: center; flex-wrap: wrap; margin-bottom: .5rem; }
89
+ .badge { background: var(--accent-bg); color: var(--accent-text); border-radius: 999px; padding: .05rem .5rem; font-size: .75rem; }
90
+ .badge.dirty { background: var(--bad-bg); color: var(--bad); }
91
+
92
+ .toolbar { display: flex; gap: .5rem; align-items: center; flex-wrap: wrap; margin-bottom: .6rem; }
93
+ .btn { font: inherit; padding: .25rem .7rem; border-radius: 6px; border: 1px solid var(--line); background: var(--panel); color: var(--text); cursor: pointer; }
94
+ .btn:hover:not(:disabled) { border-color: var(--accent); }
95
+ .btn:disabled { opacity: .4; cursor: default; }
96
+ .btn.primary { border-color: var(--accent); background: var(--accent-bg); color: var(--accent-text); }
97
+ .btn.danger-btn { border-color: var(--bad); color: var(--bad); }
98
+
99
+ .scroll { overflow: auto; border: 1px solid var(--line); border-radius: 8px; max-height: 70vh; }
100
+ .grid { border-collapse: collapse; width: 100%; font-size: .82rem; }
101
+ .grid th { text-align: left; padding: .45rem .7rem; background: var(--panel); cursor: pointer; white-space: nowrap; position: sticky; top: 0; z-index: 1; }
102
+ .grid th.pk::after { content: ' PK'; color: var(--faint); font-size: .7rem; }
103
+ .grid td { padding: .3rem .7rem; border-top: 1px solid var(--line-soft); font-family: ui-monospace, monospace; white-space: nowrap; max-width: 26rem; overflow: hidden; text-overflow: ellipsis; }
104
+ .grid td.null { color: var(--faint); font-style: italic; }
105
+ .grid td.num { text-align: right; }
106
+ .grid td.staged { background: var(--accent-bg); }
107
+ .grid td.bad { outline: 1px solid var(--bad); background: var(--bad-bg); }
108
+ .grid td.at { outline: 2px solid var(--accent); outline-offset: -2px; }
109
+ .grid td.editing { padding: .1rem .2rem; white-space: normal; overflow: visible; }
110
+ .grid td.pick, .grid th.pick { width: 2rem; text-align: center; }
111
+ .grid tr.focused { background: #1b2436; }
112
+ .grid tr[aria-busy='true'] { opacity: .5; }
113
+ .fk { font: inherit; background: none; border: 0; border-bottom: 1px dotted var(--accent); color: var(--accent-text); cursor: pointer; padding: 0; }
114
+
115
+ .row-bar-row { display: none; }
116
+ .row-bar-row.open { display: table-row; }
117
+ .row-bar-row td { background: var(--panel); }
118
+ .row-bar { display: flex; gap: .5rem; align-items: center; flex-wrap: wrap; }
119
+
120
+ .editor { display: flex; gap: .25rem; align-items: center; }
121
+ .editor.invalid .ed { border-color: var(--bad); }
122
+ .editor.is-null .ed { opacity: .4; }
123
+ .ed { font: inherit; font-family: ui-monospace, monospace; background: var(--bg); color: var(--text); border: 1px solid var(--accent); border-radius: 4px; padding: .15rem .3rem; min-width: 6rem; }
124
+ .ed-json { min-width: 22rem; }
125
+ .ed-long { min-width: 22rem; resize: vertical; }
126
+ .ed-check { min-width: 0; }
127
+ .ed-null { font: inherit; width: 1.6rem; border-radius: 4px; border: 1px solid var(--line); background: var(--panel); color: var(--dim); cursor: pointer; }
128
+ .ed-null[aria-pressed='true'] { border-color: var(--accent); color: var(--accent-text); }
129
+ .ed-null:disabled { opacity: .3; cursor: not-allowed; }
130
+ .ed-note { color: var(--bad); font-size: .75rem; }
131
+
132
+ .pager { margin-top: .8rem; display: flex; gap: .5rem; align-items: center; }
133
+ .filters { display: flex; gap: .4rem; flex-wrap: wrap; margin-bottom: .5rem; }
134
+ .filters input { font: inherit; font-size: .78rem; background: var(--panel); color: var(--text); border: 1px solid var(--line); border-radius: 4px; padding: .1rem .35rem; width: 8rem; }
135
+
136
+ dialog { background: var(--panel); color: var(--text); border: 1px solid var(--line); border-radius: 10px; padding: 1rem 1.2rem; max-width: 42rem; }
137
+ dialog.wide { max-width: 80rem; width: 90vw; }
138
+ dialog::backdrop { background: #0008; }
139
+ dialog h3 { margin: 0 0 .5rem; }
140
+ dialog h4 { margin: .8rem 0 .3rem; font-size: .85rem; color: var(--dim); }
141
+ .sel { font: inherit; background: var(--bg); color: var(--text); border: 1px solid var(--line); border-radius: 4px; padding: .15rem .3rem; }
142
+
143
+ /* The row side panel — where JSON, long text and both graph directions live. */
144
+ .panel { position: fixed; top: 0; right: 0; width: min(38rem, 100vw); height: 100vh; overflow-y: auto; background: var(--panel); border-left: 1px solid var(--line); padding: 1rem; z-index: 30; }
145
+ .panel-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 1rem; }
146
+ .panel-title h3 { margin: 0; font-family: ui-monospace, monospace; }
147
+ .panel-fields { display: flex; flex-direction: column; gap: .6rem; margin: .8rem 0; }
148
+ .panel-field { display: flex; flex-direction: column; gap: .15rem; }
149
+ .panel-label { font-family: ui-monospace, monospace; font-size: .8rem; }
150
+ .panel-value { font-family: ui-monospace, monospace; color: var(--dim); word-break: break-all; }
151
+ .panel-refs { margin-top: 1rem; }
152
+ .panel-ref { display: flex; gap: .5rem; align-items: center; margin: .3rem 0; }
153
+ .panel-field .editor { width: 100%; }
154
+ .panel-field .ed { width: 100%; }
155
+
156
+ .undo-bar { position: fixed; left: 50%; bottom: 1.2rem; transform: translateX(-50%); display: flex; gap: .8rem; align-items: center; background: var(--panel); border: 1px solid var(--line); border-radius: 8px; padding: .5rem .9rem; z-index: 40; }
157
+ .undo-bar.error { border-color: var(--bad); color: var(--bad); }
158
+
159
+ .drop { border: 2px dashed var(--line); border-radius: 10px; padding: 2rem; text-align: center; }
160
+ .drop.over { border-color: var(--accent); background: var(--accent-bg); }
161
+ .import-stage { display: flex; flex-direction: column; gap: .4rem; max-height: 75vh; overflow-y: auto; }
162
+ .import-head { display: flex; gap: .6rem; align-items: center; flex-wrap: wrap; }
163
+ .map-row { display: grid; grid-template-columns: 10rem 14rem 14rem auto auto; gap: .5rem; align-items: center; padding: .15rem 0; }
164
+ .map-name { font-family: ui-monospace, monospace; overflow: hidden; text-overflow: ellipsis; }
165
+ .map-samples { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
166
+ .unmapped-row { display: flex; gap: .5rem; align-items: center; font-family: ui-monospace, monospace; font-size: .8rem; }
167
+ .tag { border-radius: 4px; padding: 0 .35rem; font-size: .7rem; font-family: ui-sans-serif, system-ui, sans-serif; }
168
+ .tag.required { background: var(--bad-bg); color: var(--bad); }
169
+ .tag.default { background: var(--accent-bg); color: var(--accent-text); }
170
+ .tag.nullable { background: #1a1f2b; color: var(--dim); }
171
+ .insert-rows { display: flex; flex-direction: column; gap: .8rem; max-height: 50vh; overflow-y: auto; }
172
+ .insert-row { display: grid; grid-template-columns: repeat(auto-fill, minmax(12rem, 1fr)); gap: .4rem; border-top: 1px solid var(--line-soft); padding-top: .4rem; }
173
+ `
174
+
175
+ export const SHELL: string = `<!DOCTYPE html>
176
+ <html lang="en">
177
+ <head>
178
+ <meta charset="UTF-8">
179
+ <meta name="viewport" content="width=device-width, initial-scale=1">
180
+ <title>Database explorer</title>
181
+ <style>${CSS}</style>
182
+ </head>
183
+ <body>
184
+ <div id="app"><p class="note" style="padding:1rem">loading…</p></div>
185
+ <script type="module" src="/_db/app.js"></script>
186
+ </body>
187
+ </html>`
@@ -0,0 +1,295 @@
1
+ /**
2
+ * Turning a request body into bound values, or into a list of what is wrong
3
+ * with it.
4
+ *
5
+ * Two rules run through every function here.
6
+ *
7
+ * **Errors accumulate.** A 400 names every bad field, not the first one — a
8
+ * fifty-row paste fixed one error per round trip is fifty round trips, and the
9
+ * user cannot see the shape of their mistake.
10
+ *
11
+ * **Nothing is applied partially.** These functions only ever *describe* a
12
+ * write; the caller runs the statement, and only when the error list is empty.
13
+ * That is what makes "413 with nothing executed" and "400 with nothing
14
+ * executed" true statements rather than intentions.
15
+ *
16
+ * And an unknown column is an error, never a silently dropped key. A typo'd
17
+ * column in an edit that reports success is a row the user believes they
18
+ * changed and did not.
19
+ */
20
+
21
+ import type { ColumnFacts, TableFacts } from './identity'
22
+ import {
23
+ type ColumnMeta,
24
+ coerceValue,
25
+ comparableKind,
26
+ omittableOnInsert,
27
+ } from './shared/coerce'
28
+
29
+ export interface FieldError {
30
+ /** 0-based index into the request's `rows`/`edits`/`keys` array. */
31
+ row: number
32
+ column: string
33
+ code: string
34
+ message: string
35
+ }
36
+
37
+ export interface Validated {
38
+ /** Raw database column name → bound value. */
39
+ values: Record<string, unknown>
40
+ errors: FieldError[]
41
+ }
42
+
43
+ /** A request body member that has to be a plain object before anything else. */
44
+ export function isRecord(value: unknown): value is Record<string, unknown> {
45
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
46
+ }
47
+
48
+ const error = (
49
+ row: number,
50
+ column: string,
51
+ code: string,
52
+ message: string,
53
+ ): FieldError => ({ row, column, code, message })
54
+
55
+ /**
56
+ * Resolve a wire column name against the table.
57
+ *
58
+ * Accepts the raw database name, and the camel spelling as a courtesy — the
59
+ * grid renders raw names, but a script written against a typed schema has camel
60
+ * ones and the two are the same column. Anything else is `unknown_column`.
61
+ */
62
+ function findColumn(table: TableFacts, name: string): ColumnFacts | undefined {
63
+ return (
64
+ table.byName.get(name) ??
65
+ table.columns.find(column => column.camel === name)
66
+ )
67
+ }
68
+
69
+ function coerceInto(
70
+ values: Record<string, unknown>,
71
+ errors: FieldError[],
72
+ row: number,
73
+ column: ColumnFacts,
74
+ raw: unknown,
75
+ ): void {
76
+ const result = coerceValue(raw, column.meta)
77
+ if (result.ok) values[column.name] = result.value
78
+ else errors.push(error(row, column.name, result.code, result.message))
79
+ }
80
+
81
+ /**
82
+ * A whole row, for an INSERT.
83
+ *
84
+ * Every column is considered, not only the ones the caller sent: an absent key
85
+ * is fine when the database can fill it in (auto-increment, a default, or
86
+ * nullable) and is a `required` error otherwise. Refusing here rather than at
87
+ * the database is what keeps a 1,000-row insert from failing on row 700 with
88
+ * 699 rows already written — the whole statement is known to be well-formed
89
+ * before any of it runs.
90
+ */
91
+ export function validateInsertRow(
92
+ input: unknown,
93
+ table: TableFacts,
94
+ row: number,
95
+ ): Validated {
96
+ const values: Record<string, unknown> = {}
97
+ const errors: FieldError[] = []
98
+
99
+ if (!isRecord(input)) {
100
+ return {
101
+ values,
102
+ errors: [error(row, '', 'not_a_row', 'expected an object')],
103
+ }
104
+ }
105
+
106
+ const seen = new Set<string>()
107
+ for (const [name, raw] of Object.entries(input)) {
108
+ const column = findColumn(table, name)
109
+ if (!column) {
110
+ errors.push(error(row, name, 'unknown_column', `no column named ${name}`))
111
+ continue
112
+ }
113
+ seen.add(column.name)
114
+ coerceInto(values, errors, row, column, raw)
115
+ }
116
+
117
+ for (const column of table.columns) {
118
+ if (seen.has(column.name)) continue
119
+ if (omittableOnInsert(column.meta)) continue
120
+ errors.push(
121
+ error(
122
+ row,
123
+ column.name,
124
+ 'required',
125
+ `${column.name} cannot be null and has no default`,
126
+ ),
127
+ )
128
+ }
129
+
130
+ return { values, errors }
131
+ }
132
+
133
+ export interface PartialOptions {
134
+ /**
135
+ * Whether a `json` or `buffer` column may appear.
136
+ *
137
+ * `false` for `expect`, where the answer is not "be careful" but "this cannot
138
+ * be expressed": those two kinds have no portable equality predicate — see
139
+ * `comparableKind` — so an `expect` over one is a condition that never
140
+ * matches, which reads to the caller as a permanent conflict.
141
+ */
142
+ allowUncomparable: boolean
143
+ /** Which field of the request this is, for the message. */
144
+ label: string
145
+ }
146
+
147
+ /**
148
+ * A partial row — the `set` of an update, or the `expect` it is guarded by.
149
+ *
150
+ * Absent keys are absent, and that is the point: this function has no concept
151
+ * of a default, because "the caller did not mention this column" means "leave
152
+ * it alone" everywhere it is used.
153
+ */
154
+ export function validatePartial(
155
+ input: unknown,
156
+ table: TableFacts,
157
+ row: number,
158
+ options: PartialOptions,
159
+ ): Validated {
160
+ const values: Record<string, unknown> = {}
161
+ const errors: FieldError[] = []
162
+
163
+ if (!isRecord(input)) {
164
+ return {
165
+ values,
166
+ errors: [
167
+ error(row, '', 'not_an_object', `${options.label} must be an object`),
168
+ ],
169
+ }
170
+ }
171
+
172
+ for (const [name, raw] of Object.entries(input)) {
173
+ const column = findColumn(table, name)
174
+ if (!column) {
175
+ errors.push(error(row, name, 'unknown_column', `no column named ${name}`))
176
+ continue
177
+ }
178
+ if (!options.allowUncomparable && !comparableKind(column.meta.kind)) {
179
+ errors.push(
180
+ error(
181
+ row,
182
+ column.name,
183
+ 'uncomparable',
184
+ `a ${column.meta.kind} column cannot be compared in ${options.label}; ` +
185
+ 'no dialect agrees on equality for one',
186
+ ),
187
+ )
188
+ continue
189
+ }
190
+ coerceInto(values, errors, row, column, raw)
191
+ }
192
+
193
+ return { values, errors }
194
+ }
195
+
196
+ /**
197
+ * The columns of `set` that may only be written with `force`.
198
+ *
199
+ * A `json` or `buffer` column cannot appear in `expect`, so an edit to one is
200
+ * unguarded by construction — it overwrites whatever is there, including
201
+ * whatever somebody else wrote a moment ago. `force` is how the caller says
202
+ * they know that. It is not a permission; it is an acknowledgement.
203
+ */
204
+ export function unguardableColumns(
205
+ values: Record<string, unknown>,
206
+ table: TableFacts,
207
+ ): string[] {
208
+ return Object.keys(values).filter(name => {
209
+ const meta: ColumnMeta | undefined = table.byName.get(name)?.meta
210
+ return meta ? !comparableKind(meta.kind) : false
211
+ })
212
+ }
213
+
214
+ export interface ValidatedKey {
215
+ /** Raw column name → bound value, for the identity predicate. */
216
+ where: Record<string, unknown>
217
+ errors: FieldError[]
218
+ }
219
+
220
+ /**
221
+ * A wire key, against the table's identity.
222
+ *
223
+ * **The column set must be exactly equal** — not a subset, not a superset. A
224
+ * subset is a predicate that matches more than one row, which for a composite
225
+ * key is precisely the dashboard's bug (its MySQL path addresses rows by the
226
+ * *first* primary-key column, so one edit rewrites every row sharing it). A
227
+ * superset is a caller who believes the key contains something it does not, and
228
+ * silently ignoring the extra column would confirm that belief.
229
+ *
230
+ * The key is transparent by design — `{ col: value }` rather than an opaque
231
+ * token — so there is no server-side key cache to keep, and nothing that can go
232
+ * stale between rendering a page and saving a row (convention 6).
233
+ */
234
+ export function validateKey(
235
+ input: unknown,
236
+ table: TableFacts,
237
+ row: number,
238
+ ): ValidatedKey {
239
+ const where: Record<string, unknown> = {}
240
+ const errors: FieldError[] = []
241
+
242
+ if (!isRecord(input)) {
243
+ return {
244
+ where,
245
+ errors: [error(row, '', 'not_a_key', 'key must be an object')],
246
+ }
247
+ }
248
+
249
+ const wanted = table.identity.cols
250
+ const resolved = new Map<string, unknown>()
251
+ for (const [name, raw] of Object.entries(input)) {
252
+ const column = findColumn(table, name)
253
+ if (!column) {
254
+ errors.push(error(row, name, 'unknown_column', `no column named ${name}`))
255
+ continue
256
+ }
257
+ resolved.set(column.name, raw)
258
+ }
259
+
260
+ const missing = wanted.filter(name => !resolved.has(name))
261
+ const extra = [...resolved.keys()].filter(name => !wanted.includes(name))
262
+ if (missing.length || extra.length) {
263
+ errors.push(
264
+ error(
265
+ row,
266
+ [...missing, ...extra].join(', '),
267
+ 'key_mismatch',
268
+ `key must name exactly ${wanted.join(', ')}` +
269
+ (missing.length ? `; missing ${missing.join(', ')}` : '') +
270
+ (extra.length ? `; unexpected ${extra.join(', ')}` : ''),
271
+ ),
272
+ )
273
+ return { where, errors }
274
+ }
275
+
276
+ for (const name of wanted) {
277
+ const column = table.byName.get(name)!
278
+ const raw = resolved.get(name)
279
+ if (raw === null) {
280
+ // `col = NULL` is never true. A key column that is null is not a key.
281
+ errors.push(
282
+ error(
283
+ row,
284
+ name,
285
+ 'null_key',
286
+ `${name} is part of the key and cannot be null`,
287
+ ),
288
+ )
289
+ continue
290
+ }
291
+ coerceInto(where, errors, row, column, raw)
292
+ }
293
+
294
+ return { where, errors }
295
+ }
package/src/authorize.ts DELETED
@@ -1,82 +0,0 @@
1
- import {
2
- getClientIp,
3
- requestHasCredential,
4
- } from '@bakery-framework/core/utils/http'
5
-
6
- /**
7
- * Decides whether a request may use the explorer.
8
- *
9
- * Same design as the dashboard's guard, for the same reason: the explorer
10
- * authenticates nobody itself. The host application, which already knows who
11
- * its users are, supplies a predicate; without one, access is loopback-only
12
- * in development and denied outright in production, so an unconfigured
13
- * explorer is never exposed.
14
- */
15
- export type AuthorizeFn = (req: Request) => boolean | Promise<boolean>
16
-
17
- /**
18
- * Addresses only, never hostnames: a peer address is not the string
19
- * `localhost`, and matching the request's hostname would trust a header the
20
- * client chooses (`Host: localhost` from anywhere on the LAN, with the dev
21
- * server listening on 0.0.0.0).
22
- */
23
- const LOOPBACK = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1'])
24
-
25
- /** True when the request came from this machine. */
26
- export function isLoopback(req: Request): boolean {
27
- // The peer address is the only evidence here the client does not choose.
28
- // getClientIp reads config and the live server, either of which may be
29
- // absent (tests, early boot). An address that cannot be determined is
30
- // indeterminate, and per convention 2 an indeterminate answer is a denial —
31
- // not a reason to consult something the requester controls.
32
- let ip = ''
33
- try {
34
- ip = getClientIp(req)
35
- } catch {
36
- return false
37
- }
38
- return LOOPBACK.has(ip)
39
- }
40
-
41
- /**
42
- * The default: loopback in dev, nothing in prod. `import.meta.env.PROD` read
43
- * at call time — the flag is process state, and tests flip it.
44
- */
45
- export function defaultAuthorize(req: Request): boolean {
46
- if (import.meta.env.PROD) return false
47
- return isLoopback(req)
48
- }
49
-
50
- export function resolveAuthorize(fn?: AuthorizeFn): AuthorizeFn {
51
- return fn ?? defaultAuthorize
52
- }
53
-
54
- /**
55
- * Guard semantics per convention 2: the *authorizer* may throw or hang-fail;
56
- * the answer to any indeterminate state is denial.
57
- */
58
- export async function isAuthorized(
59
- authorize: AuthorizeFn,
60
- req: Request,
61
- ): Promise<boolean> {
62
- try {
63
- return (await authorize(req)) === true
64
- } catch {
65
- // A predicate that throws is indeterminate, and indeterminate is denied.
66
- return false
67
- }
68
- }
69
-
70
- /**
71
- * Shared-credential access: `dbExplorerPlugin({ credential: import.meta.env
72
- * .DB_EXPLORER_KEY })`, presented as `x-db-key`, a Bearer token, or a
73
- * one-time `?db-key=` query the client strips from the URL. The comparison
74
- * lives in core (`requestHasCredential`) — one copy, shared with analytics;
75
- * this only names the key, `db-key`.
76
- */
77
- export function credentialMatches(
78
- credential: string | undefined,
79
- req: Request,
80
- ): boolean {
81
- return requestHasCredential(req, credential, 'db-key')
82
- }
package/src/endpoints.ts DELETED
@@ -1,48 +0,0 @@
1
- import type { JsonResponseData } from '@bakery-framework/core/utils/common'
2
- import { Try } from '@bakery-framework/core/utils/common'
3
- import { response } from '@bakery-framework/core/utils/http'
4
- import { connection } from '@bakery-framework/orm/connection'
5
-
6
- /**
7
- * The explorer's whole write surface, enumerated: there is none.
8
- *
9
- * Both endpoints are reads, there is no raw-SQL endpoint, and no row
10
- * mutations — that is the plugin's contract, not a configuration. The
11
- * dashboard's `DASHBOARD_ALLOW_WRITES` gate exists because the dashboard
12
- * *has* write paths to gate; the explorer removes the paths instead of
13
- * gating them, so there is no flag to leave set by accident and no second
14
- * write path for a gate to miss.
15
- */
16
-
17
- export async function handleSchema(): Promise<JsonResponseData<unknown>> {
18
- return await Try.return(
19
- async () => response.json.success('success', await connection.getSchema()),
20
- () => response.json.error(500, 'Failed to retrieve schema details'),
21
- )
22
- }
23
-
24
- /** Table names the way the ORM writes them: identifier characters only. */
25
- const RX_TABLE_NAME = /^[a-zA-Z0-9_]+$/
26
-
27
- export async function handleTableData(
28
- url: URL,
29
- ): Promise<JsonResponseData<unknown>> {
30
- const tableName = url.searchParams.get('tableName')
31
- if (!tableName || !RX_TABLE_NAME.test(tableName)) {
32
- return response.json.error(400, 'Invalid table name')
33
- }
34
-
35
- return await Try.return(
36
- async () => {
37
- const data = await connection.getData(tableName, {
38
- page: Number.parseInt(url.searchParams.get('page') || '1', 10),
39
- pageSize: Number.parseInt(url.searchParams.get('pageSize') || '50', 10),
40
- sortBy: url.searchParams.get('sortBy'),
41
- sortOrder: url.searchParams.get('sortOrder') || 'ASC',
42
- filters: JSON.parse(url.searchParams.get('filters') || '{}'),
43
- })
44
- return response.json.success('success', data)
45
- },
46
- (error: any) => response.json.error(400, error.message),
47
- )
48
- }