@bakery-framework/plugin-db-explorer 2.0.0-alpha.11

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 (49) hide show
  1. package/package.json +42 -0
  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 +409 -0
  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 +175 -0
  38. package/src/endpoints/rows.ts +435 -0
  39. package/src/identity.ts +401 -0
  40. package/src/index.ts +68 -0
  41. package/src/policy.ts +45 -0
  42. package/src/preview.ts +53 -0
  43. package/src/setup.ts +147 -0
  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
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
+ }