@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.
- package/package.json +4 -4
- package/src/access.ts +188 -0
- package/src/client/api.ts +261 -0
- package/src/client/bulk.ts +361 -0
- package/src/client/cell.ts +139 -0
- package/src/client/confirm.ts +201 -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 +91 -0
- package/src/client/csv.ts +104 -0
- package/src/client/dom.ts +144 -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 +242 -0
- package/src/client/grid-body.ts +103 -0
- package/src/client/grid-header.ts +65 -0
- package/src/client/grid-rowbar.ts +64 -0
- package/src/client/grid.ts +466 -0
- package/src/client/meta.ts +188 -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 +209 -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 +231 -0
- package/src/client/tabs.ts +224 -0
- package/src/client/tabstrip.ts +127 -0
- package/src/client.ts +374 -160
- package/src/endpoints/common.ts +122 -0
- package/src/endpoints/graph.ts +0 -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 +235 -0
- package/src/shared/filters.ts +200 -0
- package/src/shared/plan.ts +164 -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
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>`
|
package/src/validate.ts
ADDED
|
@@ -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/credential.ts
DELETED
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
import { requestHasCredential } from '@bakery-framework/core/utils/http'
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Shared-credential access: `dbExplorerPlugin({ credential: import.meta.env
|
|
5
|
-
* .DB_EXPLORER_KEY })`, presented as `x-db-key`, a Bearer token, or a
|
|
6
|
-
* one-time `?db-key=` query the client strips from the URL. The comparison
|
|
7
|
-
* lives in core (`requestHasCredential`) — one copy, shared with analytics;
|
|
8
|
-
* this only names the key, `db-key`.
|
|
9
|
-
*
|
|
10
|
-
* Named `hasDbKey`, not `credentialMatches`, deliberately. Core exports a
|
|
11
|
-
* `credentialMatches` of its own from the very barrel this file imports
|
|
12
|
-
* (`@bakery-framework/core/utils/http`), and it takes
|
|
13
|
-
* `(configured, presented: string | null | undefined)` where this takes
|
|
14
|
-
* `(credential, req: Request)`. Two same-named functions with the same arity
|
|
15
|
-
* and a different second parameter, one of them reachable by wildcard from a
|
|
16
|
-
* module every caller here already imports, is a trap: the compiler catches
|
|
17
|
-
* the direct substitution today only because `Request` is not assignable to
|
|
18
|
-
* `string | null | undefined`, which is a coincidence of the current types
|
|
19
|
-
* rather than a guarantee. The distinct name removes the question.
|
|
20
|
-
*/
|
|
21
|
-
export function hasDbKey(
|
|
22
|
-
credential: string | undefined,
|
|
23
|
-
req: Request,
|
|
24
|
-
): boolean {
|
|
25
|
-
return requestHasCredential(req, credential, 'db-key')
|
|
26
|
-
}
|
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
|
-
}
|