@bakery-framework/plugin-db-explorer 2.0.0-alpha.5 → 2.0.0-alpha.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/package.json +4 -4
  2. package/src/access.ts +188 -0
  3. package/src/client/api.ts +279 -0
  4. package/src/client/bulk.ts +357 -0
  5. package/src/client/cell.ts +139 -0
  6. package/src/client/confirm.ts +203 -0
  7. package/src/client/csv-commit.ts +185 -0
  8. package/src/client/csv-map.ts +274 -0
  9. package/src/client/csv-model.ts +420 -0
  10. package/src/client/csv-pick.ts +54 -0
  11. package/src/client/csv-preview.ts +89 -0
  12. package/src/client/csv.ts +104 -0
  13. package/src/client/dom.ts +164 -0
  14. package/src/client/edit-session.ts +219 -0
  15. package/src/client/editors.ts +283 -0
  16. package/src/client/filter-builder.ts +198 -0
  17. package/src/client/fk.ts +269 -0
  18. package/src/client/grid-body.ts +106 -0
  19. package/src/client/grid-header.ts +65 -0
  20. package/src/client/grid-rowbar.ts +64 -0
  21. package/src/client/grid.ts +468 -0
  22. package/src/client/meta.ts +185 -0
  23. package/src/client/page.ts +332 -0
  24. package/src/client/panel.ts +296 -0
  25. package/src/client/relations.ts +205 -0
  26. package/src/client/save.ts +205 -0
  27. package/src/client/sidebar.ts +110 -0
  28. package/src/client/state.ts +218 -0
  29. package/src/client/statusbar.ts +130 -0
  30. package/src/client/structure.ts +234 -0
  31. package/src/client/tabs.ts +219 -0
  32. package/src/client/tabstrip.ts +127 -0
  33. package/src/client.ts +376 -160
  34. package/src/endpoints/common.ts +122 -0
  35. package/src/endpoints/graph.ts +148 -0
  36. package/src/endpoints/import.ts +89 -0
  37. package/src/endpoints/read.ts +173 -0
  38. package/src/endpoints/rows.ts +435 -0
  39. package/src/identity.ts +391 -0
  40. package/src/index.ts +40 -45
  41. package/src/policy.ts +45 -0
  42. package/src/preview.ts +53 -0
  43. package/src/setup.ts +64 -81
  44. package/src/shared/coerce.ts +399 -0
  45. package/src/shared/csv.ts +186 -0
  46. package/src/shared/filters.ts +200 -0
  47. package/src/shared/plan.ts +173 -0
  48. package/src/shell.ts +187 -0
  49. package/src/validate.ts +295 -0
  50. package/src/credential.ts +0 -26
  51. package/src/endpoints.ts +0 -48
package/src/setup.ts CHANGED
@@ -5,15 +5,18 @@ import { errorMsg } from '@bakery-framework/core/logger'
5
5
  import type { PluginRouteTable } from '@bakery-framework/core/plugins'
6
6
  import { routeTable } from '@bakery-framework/core/plugins'
7
7
  import { fs } from '@bakery-framework/core/utils'
8
+ import { response } from '@bakery-framework/core/utils/http'
9
+ import { type AccessConfig, accessStore, resolveAccess } from './access'
10
+ import { handleGraph, handleLookup } from './endpoints/graph'
11
+ import { handleImport } from './endpoints/import'
12
+ import { handleSchema, handleTableData } from './endpoints/read'
8
13
  import {
9
- type AuthorizeFn,
10
- defaultAuthorize,
11
- isAuthorized,
12
- resolveAuthorize,
13
- response,
14
- } from '@bakery-framework/core/utils/http'
15
- import { hasDbKey } from './credential'
16
- import { handleSchema, handleTableData } from './endpoints'
14
+ handleBulkEdit,
15
+ handleDeleteRows,
16
+ handleInsertRows,
17
+ handleUpdateRow,
18
+ } from './endpoints/rows'
19
+ import { SHELL } from './shell'
17
20
 
18
21
  /**
19
22
  * Where this plugin's own files live — each package that ships files anchors
@@ -22,64 +25,21 @@ import { handleSchema, handleTableData } from './endpoints'
22
25
  */
23
26
  const pluginRoot: string = fs.resolve(import.meta.dir)
24
27
 
25
- let authorize: AuthorizeFn = defaultAuthorize
26
- let credential: string | undefined
28
+ let config: AccessConfig = {}
27
29
 
28
30
  /**
29
- * Test seam, same shape as the dashboard's: `setupExplorer` mutates process
30
- * globals that cannot be restored, so tests that only need the request
31
- * pipeline set the predicate directly. Always pair with the reset.
31
+ * Test seam: `setupExplorer` registers a handler and cannot be undone, so
32
+ * tests that only need the request pipeline set the access config directly.
33
+ * Always pair with the reset.
32
34
  */
33
- export function __setTestAuthorize(fn: AuthorizeFn): void {
34
- authorize = fn
35
+ export function __setTestAccess(next: AccessConfig): void {
36
+ config = next
35
37
  }
36
38
 
37
- export function __resetTestAuthorize(): void {
38
- authorize = defaultAuthorize
39
- credential = undefined
39
+ export function __resetTestAccess(): void {
40
+ config = {}
40
41
  }
41
42
 
42
- /** Test seam for the credential path; reset with __resetTestAuthorize. */
43
- export function __setTestCredential(value: string | undefined): void {
44
- credential = value
45
- }
46
-
47
- const SHELL = `<!DOCTYPE html>
48
- <html lang="en">
49
- <head>
50
- <meta charset="UTF-8">
51
- <title>Database explorer</title>
52
- <style>
53
- :root { color-scheme: dark; }
54
- * { box-sizing: border-box; }
55
- body { margin: 0; font: 14px/1.5 ui-sans-serif, system-ui, sans-serif; background: #0f1115; color: #e6e8ee; }
56
- #app { display: flex; min-height: 100vh; }
57
- .side { width: 220px; padding: 1rem; border-right: 1px solid #262b36; flex-shrink: 0; }
58
- .brand { font-size: 1rem; margin: 0; }
59
- .note { color: #9aa3b2; font-size: 0.8rem; }
60
- .error { color: #ff8ba0; padding: 1rem; }
61
- .table-btn { display: block; width: 100%; text-align: left; background: none; border: 0; color: #cfd6e4; padding: 0.35rem 0.5rem; border-radius: 6px; cursor: pointer; font: inherit; }
62
- .table-btn:hover { background: #1a1f2b; }
63
- .table-btn.active { background: #16233d; color: #cfe0ff; }
64
- .main { flex: 1; padding: 1rem 1.5rem; min-width: 0; }
65
- .table-head { display: flex; align-items: baseline; gap: 1rem; }
66
- .table-head h2 { margin: 0.2rem 0 0.8rem; font-family: ui-monospace, monospace; font-size: 1rem; }
67
- .scroll { overflow-x: auto; border: 1px solid #262b36; border-radius: 8px; }
68
- .grid { border-collapse: collapse; width: 100%; font-size: 0.82rem; }
69
- .grid th { text-align: left; padding: 0.45rem 0.7rem; background: #151922; cursor: pointer; white-space: nowrap; position: sticky; top: 0; }
70
- .grid td { padding: 0.35rem 0.7rem; border-top: 1px solid #1e2430; font-family: ui-monospace, monospace; white-space: nowrap; max-width: 26rem; overflow: hidden; text-overflow: ellipsis; }
71
- .grid td.null { color: #5b6472; font-style: italic; }
72
- .pager { margin-top: 0.8rem; display: flex; gap: 0.5rem; }
73
- .pager button { font: inherit; padding: 0.3rem 0.8rem; border-radius: 6px; border: 1px solid #2f6feb; background: #16233d; color: #cfe0ff; cursor: pointer; }
74
- .pager button:disabled { opacity: 0.4; cursor: default; }
75
- </style>
76
- </head>
77
- <body>
78
- <div id="app"><p class="note" style="padding:1rem">loading…</p></div>
79
- <script type="module" src="/_db/app.js"></script>
80
- </body>
81
- </html>`
82
-
83
43
  let cachedClientJs: string | null = null
84
44
 
85
45
  async function handleClientJs() {
@@ -103,19 +63,42 @@ async function handleClientJs() {
103
63
  }
104
64
 
105
65
  /**
106
- * Read-only by construction: the two data endpoints call only `getSchema`
107
- * and `getData`. No raw SQL, no row mutations, no DDL the write paths do
108
- * not exist, which is a stronger property than any gate over them. The keys
109
- * are method-unqualified because a bare key matches any method and every
110
- * handler here is a read; there is nothing a smuggled POST could mutate,
111
- * which is also why this table carries no CSRF middleware where the
112
- * dashboard's must.
66
+ * The whole request surface, and **the key spellings are the CSRF policy** —
67
+ * `guardFor` in `plugins/routes.ts` reads them, so getting one wrong here
68
+ * silently loosens a guard rather than failing anywhere visible.
69
+ *
70
+ * - **Bare keys are the reads.** A bare key matches every method and gets
71
+ * `checkSameOrigin` on *all* of them, which is the stricter of the two: no
72
+ * cross-site page reaches these, whatever verb it uses.
73
+ * - **Every write key names its method.** That pins the verb — a `GET
74
+ * /api/_db/rows` no longer resolves at all — and applies `checkCsrf`.
75
+ *
76
+ * This comment used to say the table needed no CSRF middleware because nothing
77
+ * here could mutate anything. That was true when the plugin was read-only and
78
+ * is now false: rows are inserted, edited and deleted below. What is still
79
+ * true, and is the claim that survives, is **structural**: there is no
80
+ * raw-SQL endpoint and nothing that creates, drops or alters a table. The write
81
+ * surface is bounded and enumerable — it is exactly the five method-qualified
82
+ * keys below — rather
83
+ * than gated behind a flag the way the dashboard's is.
84
+ *
85
+ * `/api/_db/graph` and `/api/_db/lookup` are reads despite one of them taking a
86
+ * POST body, so they stay bare and take the stricter guard. Method-qualifying
87
+ * `GET /api/_db/graph` would *weaken* it: a qualified GET gets `checkCsrf`,
88
+ * which lets every GET through by definition.
113
89
  */
114
- const explorerRoutes = {
90
+ export const explorerRoutes = {
115
91
  '/_db': () => response.html(SHELL),
116
92
  '/_db/app.js': () => handleClientJs(),
117
93
  '/api/_db/schema': () => handleSchema(),
118
94
  '/api/_db/table-data': (_req, url) => handleTableData(url),
95
+ '/api/_db/graph': () => handleGraph(),
96
+ '/api/_db/lookup': req => handleLookup(req),
97
+ 'POST /api/_db/rows': req => handleInsertRows(req),
98
+ 'PATCH /api/_db/row': req => handleUpdateRow(req),
99
+ 'POST /api/_db/rows/bulk': req => handleBulkEdit(req),
100
+ 'DELETE /api/_db/rows': req => handleDeleteRows(req),
101
+ 'POST /api/_db/import': req => handleImport(req),
119
102
  } satisfies PluginRouteTable
120
103
 
121
104
  const dispatchExplorerRoute = routeTable(explorerRoutes)
@@ -133,28 +116,28 @@ export class DbExplorerHandler extends Handler {
133
116
  static async handle(path: string, req: Request) {
134
117
  // Styling and script are not secrets, and letting them through keeps an
135
118
  // unauthorised response from rendering unstyled — same split as the
136
- // dashboard. Everything else fails closed. Either door admits: the
137
- // shared credential (constant-time, off when unset) or the predicate.
138
- const admitted =
139
- hasDbKey(credential, req) || (await isAuthorized(authorize, req))
140
- if (!/\.(css|js)$/.test(path) && !admitted) {
119
+ // dashboard. Everything else fails closed.
120
+ const access = await resolveAccess(req, config)
121
+ if (!/\.(css|js)$/.test(path) && !access) {
141
122
  return path.startsWith('/api/')
142
123
  ? response.error('Unauthorized', 401)
143
124
  : response.error('Not Found', 404)
144
125
  }
145
126
 
146
- // Dispatch keys on `url.pathname` from the request itself the `path`
147
- // argument only steers the auth split above.
148
- const result = await dispatchExplorerRoute(req)
149
- return result ?? response.error('Not Found', 404)
127
+ // The level travels with the request rather than in a module variable —
128
+ // see `accessStore`. `read` is the floor for the asset paths admitted
129
+ // above, which never consult it but must not run outside a store.
130
+ return await accessStore.run(access || 'read', async () => {
131
+ // Dispatch keys on `url.pathname` from the request itself — the `path`
132
+ // argument only steers the auth split above.
133
+ const result = await dispatchExplorerRoute(req)
134
+ return result ?? response.error('Not Found', 404)
135
+ })
150
136
  }
151
137
  }
152
138
 
153
- export function setupExplorer(
154
- options: { authorize?: AuthorizeFn; credential?: string } = {},
155
- ) {
156
- authorize = resolveAuthorize(options.authorize)
157
- credential = options.credential
139
+ export function setupExplorer(options: AccessConfig = {}) {
140
+ config = options
158
141
  // Above the content handlers, below nothing that matters: the /_db and
159
142
  // /api/_db namespaces are reserved for framework routes (convention 10),
160
143
  // so priority only needs to beat ApiHandler (70) for the /api half.
@@ -0,0 +1,399 @@
1
+ /**
2
+ * Wire value → column value, and the validation that goes with it.
3
+ *
4
+ * **Pure.** No `Bun.*`, no node builtins, no DOM — convention 5's rule applied
5
+ * inside the plugin, because this module is imported by the endpoints *and*
6
+ * compiled into the browser bundle, so the grid can tell a user their input is
7
+ * wrong before a request is made and get the same answer the server would give.
8
+ * One implementation is the only way those two answers stay equal.
9
+ *
10
+ * **Pure also means importing nothing from `@bakery-framework/*`, including
11
+ * `utils/isomorphic`.** That reads like the one safe exception — it is the
12
+ * framework's own pure layer — and it is not, for a mechanical reason:
13
+ * `bundleModule` marks every *installed package* external
14
+ * (`compiler.ts:316`), so a framework import survives into the emitted bundle
15
+ * as a bare specifier, the browser tries to fetch
16
+ * `@bakery-framework/core/utils/isomorphic` as a URL, and the whole module
17
+ * fails to load. Chrome reports that as "Failed to fetch dynamically imported
18
+ * module", naming `app.js` rather than the import that actually broke.
19
+ *
20
+ * A `Try` import cost exactly that, and the dashboard's client avoids every
21
+ * `@bakery-framework/*` import for the same reason without saying so anywhere.
22
+ *
23
+ * So: duplicate the three lines. The alternative is a page that does not boot.
24
+ *
25
+ * **The three wire states the dashboard collapsed into one.** Its editor read
26
+ * every cell as a string and let the driver sort it out, so there was no way to
27
+ * express "leave this alone", an empty text field cleared to `NULL`, and `""`
28
+ * into a numeric column silently became `0`. Here:
29
+ *
30
+ * - **key absent** — leave the column unchanged. Never reaches this function.
31
+ * - **`null`** — SQL NULL. Refused on a NOT NULL column rather than coerced.
32
+ * - **`""`** — the empty string. On a text column that is a value; on any
33
+ * other kind it is an error, never zero and never NULL.
34
+ *
35
+ * And `"007"` is a string in a text column and the number 7 in an integer one,
36
+ * which is the same rule stated once: the *column* decides, not the shape of
37
+ * the characters.
38
+ */
39
+
40
+ /**
41
+ * What kind of value a column holds.
42
+ *
43
+ * The ORM's own `ColumnType` plus `date`. That extra member is not reachable
44
+ * from `getConstraints()` — the ORM stores timestamps as integer seconds, so it
45
+ * reports `integer` — and exists for a column some other tool created as a real
46
+ * `DATE`/`TIMESTAMP`, which the raw SQL type from `getSchema()` reveals. A
47
+ * genuine date column binds a `Date`, and an integer timestamp binds a number;
48
+ * conflating them is how a timestamp ends up written as the year 1970.
49
+ */
50
+ export type ColumnKind =
51
+ | 'integer'
52
+ | 'number'
53
+ | 'bigint'
54
+ | 'string'
55
+ | 'boolean'
56
+ | 'json'
57
+ | 'buffer'
58
+ | 'date'
59
+
60
+ export interface ColumnMeta {
61
+ kind: ColumnKind
62
+ /** Whether SQL NULL is permitted. */
63
+ nullable: boolean
64
+ /** Character length of a sized text column, if it declares one. */
65
+ length?: number
66
+ /** Permitted values, for `Field.Enum`. */
67
+ enum?: readonly string[]
68
+ /** Whether the database supplies a value when the column is omitted. */
69
+ hasDefault: boolean
70
+ primary?: boolean
71
+ autoIncrement?: boolean
72
+ }
73
+
74
+ export type CoerceCode =
75
+ | 'not_null'
76
+ | 'empty_string'
77
+ | 'type'
78
+ | 'too_long'
79
+ | 'not_in_enum'
80
+ | 'not_integer'
81
+ | 'not_finite'
82
+ | 'bad_json'
83
+ | 'bad_date'
84
+ | 'bad_base64'
85
+ | 'bad_boolean'
86
+
87
+ export type CoerceResult =
88
+ | { ok: true; value: unknown }
89
+ | { ok: false; code: CoerceCode; message: string }
90
+
91
+ const fail = (code: CoerceCode, message: string): CoerceResult => ({
92
+ ok: false,
93
+ code,
94
+ message,
95
+ })
96
+
97
+ const ok = (value: unknown): CoerceResult => ({ ok: true, value })
98
+
99
+ /**
100
+ * Kinds whose values can be compared as an equality predicate in SQL.
101
+ *
102
+ * `json` and `buffer` cannot, portably: MySQL compares JSON structurally,
103
+ * Postgres refuses `=` on `json` outright (only `jsonb` has it), and a blob
104
+ * comparison depends on how the driver bound the parameter. An optimistic
105
+ * `expect` on one of these would be a predicate that silently never matched —
106
+ * every edit a 409 — so it is refused instead.
107
+ */
108
+ export function comparableKind(kind: ColumnKind): boolean {
109
+ return kind !== 'json' && kind !== 'buffer'
110
+ }
111
+
112
+ const RX_INTEGER = /^[+-]?\d+$/
113
+
114
+ function coerceInteger(raw: unknown, meta: ColumnMeta): CoerceResult {
115
+ let n: number | bigint
116
+ if (typeof raw === 'number') {
117
+ if (!Number.isFinite(raw)) return fail('not_finite', 'not a finite number')
118
+ if (!Number.isInteger(raw)) return fail('not_integer', 'not a whole number')
119
+ n = raw
120
+ } else if (typeof raw === 'bigint') {
121
+ n = raw
122
+ } else if (typeof raw === 'string') {
123
+ const t = raw.trim()
124
+ if (!RX_INTEGER.test(t)) return fail('not_integer', 'not a whole number')
125
+ // Through BigInt first, then narrowed: `Number('9007199254740993')` is a
126
+ // different integer from the one that was typed, and it rounds silently.
127
+ n = BigInt(t)
128
+ } else {
129
+ return fail('type', `expected a whole number, got ${typeName(raw)}`)
130
+ }
131
+
132
+ if (typeof n === 'bigint') {
133
+ // Narrowed back to a number when it fits, so the common case binds the
134
+ // type every driver has always taken. A value outside the safe range stays
135
+ // a bigint, because turning it into a `number` is the precision loss this
136
+ // branch exists to avoid — `bigint` is the ORM's own column kind for it.
137
+ const inSafeRange =
138
+ n <= BigInt(Number.MAX_SAFE_INTEGER) &&
139
+ n >= BigInt(Number.MIN_SAFE_INTEGER)
140
+ if (inSafeRange) return ok(Number(n))
141
+ if (meta.kind !== 'bigint') {
142
+ return fail('not_integer', 'outside the range of an integer column')
143
+ }
144
+ return ok(n)
145
+ }
146
+ return ok(n)
147
+ }
148
+
149
+ function coerceNumber(raw: unknown): CoerceResult {
150
+ if (typeof raw === 'number') {
151
+ return Number.isFinite(raw)
152
+ ? ok(raw)
153
+ : fail('not_finite', 'not a finite number')
154
+ }
155
+ if (typeof raw === 'bigint') return ok(Number(raw))
156
+ if (typeof raw === 'string') {
157
+ const t = raw.trim()
158
+ const n = Number(t)
159
+ // `Number('')` is 0 and `Number(' ')` is 0. The empty case is already
160
+ // refused above; this guards the whitespace-only spelling of it.
161
+ if (t === '' || !Number.isFinite(n))
162
+ return fail('not_finite', 'not a number')
163
+ return ok(n)
164
+ }
165
+ return fail('type', `expected a number, got ${typeName(raw)}`)
166
+ }
167
+
168
+ const TRUEISH = new Set(['true', 't', 'yes', 'y', '1'])
169
+ const FALSEISH = new Set(['false', 'f', 'no', 'n', '0'])
170
+
171
+ function coerceBoolean(raw: unknown): CoerceResult {
172
+ if (typeof raw === 'boolean') return ok(raw)
173
+ if (typeof raw === 'number') {
174
+ if (raw === 1) return ok(true)
175
+ if (raw === 0) return ok(false)
176
+ return fail('bad_boolean', 'expected true/false, 1 or 0')
177
+ }
178
+ if (typeof raw === 'string') {
179
+ const t = raw.trim().toLowerCase()
180
+ if (TRUEISH.has(t)) return ok(true)
181
+ if (FALSEISH.has(t)) return ok(false)
182
+ return fail('bad_boolean', `not a boolean: ${JSON.stringify(raw)}`)
183
+ }
184
+ return fail('type', `expected a boolean, got ${typeName(raw)}`)
185
+ }
186
+
187
+ /**
188
+ * A real date/time column, bound as a `Date`.
189
+ *
190
+ * Accepts an ISO-8601 string or a number of **milliseconds** since the epoch —
191
+ * milliseconds because that is what `Date.now()` and `JSON.stringify(new Date)`
192
+ * produce on the client, and a value that is ambiguous between the two units
193
+ * does not exist: an integer timestamp column reports kind `integer` and never
194
+ * arrives here.
195
+ */
196
+ function coerceDate(raw: unknown): CoerceResult {
197
+ if (raw instanceof Date) {
198
+ return Number.isNaN(raw.getTime())
199
+ ? fail('bad_date', 'invalid date')
200
+ : ok(raw)
201
+ }
202
+ if (typeof raw === 'number') {
203
+ if (!Number.isFinite(raw)) return fail('bad_date', 'invalid date')
204
+ return ok(new Date(raw))
205
+ }
206
+ if (typeof raw === 'string') {
207
+ const ms = Date.parse(raw.trim())
208
+ if (Number.isNaN(ms))
209
+ return fail('bad_date', `not a date: ${JSON.stringify(raw)}`)
210
+ return ok(new Date(ms))
211
+ }
212
+ return fail('type', `expected a date, got ${typeName(raw)}`)
213
+ }
214
+
215
+ /**
216
+ * A JSON column, stored as text.
217
+ *
218
+ * The ORM serialises nothing — `Field.Json` declares the column type and the
219
+ * application decides what goes in it — so the explorer stores text and refuses
220
+ * text that is not JSON. An object or array on the wire is serialised here
221
+ * rather than at the call site, so the grid can send either spelling.
222
+ */
223
+ function coerceJson(raw: unknown): CoerceResult {
224
+ if (typeof raw === 'string') {
225
+ try {
226
+ JSON.parse(raw)
227
+ } catch {
228
+ // Not silent: the caller gets the failure as a field error. The parse is
229
+ // only being used as a validity test, so the thrown message adds nothing
230
+ // the code does not already say.
231
+ return fail('bad_json', 'not valid JSON')
232
+ }
233
+ return ok(raw)
234
+ }
235
+ if (typeof raw === 'object' || Array.isArray(raw)) {
236
+ try {
237
+ return ok(JSON.stringify(raw))
238
+ } catch {
239
+ // A cycle, or a BigInt. Same reasoning as above.
240
+ return fail('bad_json', 'value cannot be serialised as JSON')
241
+ }
242
+ }
243
+ return fail('type', `expected JSON, got ${typeName(raw)}`)
244
+ }
245
+
246
+ const RX_BASE64 = /^[A-Za-z0-9+/]*={0,2}$/
247
+
248
+ /**
249
+ * A binary column, carried as base64.
250
+ *
251
+ * `atob` rather than `Buffer` or `Bun.from`: this module is compiled into the
252
+ * browser bundle, and `atob` is the one decoder both runtimes have.
253
+ */
254
+ function coerceBuffer(raw: unknown): CoerceResult {
255
+ if (raw instanceof Uint8Array) return ok(raw)
256
+ if (typeof raw !== 'string') {
257
+ return fail('type', `expected base64 text, got ${typeName(raw)}`)
258
+ }
259
+ const t = raw.trim()
260
+ if (t.length % 4 !== 0 || !RX_BASE64.test(t)) {
261
+ return fail('bad_base64', 'not base64')
262
+ }
263
+ try {
264
+ const binary = atob(t)
265
+ const bytes = new Uint8Array(binary.length)
266
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
267
+ return ok(bytes)
268
+ } catch {
269
+ // `atob` throws on padding it cannot place, which the regex above does not
270
+ // catch. The caller gets it as a field error, so there is nothing to log.
271
+ return fail('bad_base64', 'not base64')
272
+ }
273
+ }
274
+
275
+ function coerceString(raw: unknown, meta: ColumnMeta): CoerceResult {
276
+ if (typeof raw !== 'string') {
277
+ // Deliberately strict, and this is the `"007"` rule from the other side: a
278
+ // number arriving for a text column has already lost its leading zeros, so
279
+ // accepting it would silently store a different value than was typed.
280
+ return fail('type', `expected text, got ${typeName(raw)}`)
281
+ }
282
+ if (meta.enum && !meta.enum.includes(raw)) {
283
+ return fail('not_in_enum', `not one of: ${meta.enum.join(', ')}`)
284
+ }
285
+ if (meta.length !== undefined && raw.length > meta.length) {
286
+ return fail('too_long', `longer than ${meta.length} characters`)
287
+ }
288
+ return ok(raw)
289
+ }
290
+
291
+ function typeName(value: unknown): string {
292
+ if (value === null) return 'null'
293
+ if (Array.isArray(value)) return 'an array'
294
+ return `a ${typeof value}`
295
+ }
296
+
297
+ /**
298
+ * One wire value, for one column.
299
+ *
300
+ * `undefined` never arrives: an absent key means "leave unchanged" and is
301
+ * resolved by the caller, which is the only place that knows whether it is
302
+ * building an INSERT (where absent means "use the default") or an UPDATE
303
+ * (where it means "do not touch this column").
304
+ */
305
+ export function coerceValue(raw: unknown, meta: ColumnMeta): CoerceResult {
306
+ if (raw === null) {
307
+ return meta.nullable
308
+ ? ok(null)
309
+ : fail('not_null', 'this column cannot be null')
310
+ }
311
+
312
+ if (raw === '' && meta.kind !== 'string') {
313
+ // The whole reason this function returns a result rather than a value.
314
+ // `Number('')` is 0, `Boolean('')` is false and `new Date('')` is Invalid
315
+ // Date — three different wrong answers for the same input, which is what
316
+ // the dashboard's editor shipped.
317
+ return fail(
318
+ 'empty_string',
319
+ `an empty string is not a value for a ${meta.kind} column; send null for SQL NULL`,
320
+ )
321
+ }
322
+
323
+ switch (meta.kind) {
324
+ case 'string':
325
+ return coerceString(raw, meta)
326
+ case 'integer':
327
+ case 'bigint':
328
+ return coerceInteger(raw, meta)
329
+ case 'number':
330
+ return coerceNumber(raw)
331
+ case 'boolean':
332
+ return coerceBoolean(raw)
333
+ case 'date':
334
+ return coerceDate(raw)
335
+ case 'json':
336
+ return coerceJson(raw)
337
+ case 'buffer':
338
+ return coerceBuffer(raw)
339
+ }
340
+ }
341
+
342
+ /**
343
+ * Whether a column may be left out of an INSERT.
344
+ *
345
+ * An auto-increment key is supplied by the database, a column with a default is
346
+ * supplied by the database, and a nullable column defaults to NULL. Anything
347
+ * else has to be given a value.
348
+ */
349
+ export function omittableOnInsert(meta: ColumnMeta): boolean {
350
+ return Boolean(meta.autoIncrement) || meta.hasDefault || meta.nullable
351
+ }
352
+
353
+ /**
354
+ * "Did the user actually change this?" — deliberately loose.
355
+ *
356
+ * The two operands come from different worlds: the left is a value the database
357
+ * returned, the right is whatever JSON the browser sent, and a driver that
358
+ * hands back `1` for a boolean or a string for a `BIGINT` is not a difference
359
+ * the user made. Strict equality here would send a `set` full of columns nobody
360
+ * touched, which is how an optimistic-concurrency check turns into a conflict
361
+ * for every concurrent editor of any column.
362
+ *
363
+ * This is used to *shrink* a statement, never to decide correctness — the
364
+ * server's own conflict check is a SQL predicate, not this.
365
+ */
366
+ export function sameValue(a: unknown, b: unknown): boolean {
367
+ if (a === null || a === undefined) return b === null || b === undefined
368
+ if (b === null || b === undefined) return false
369
+ if (a instanceof Date || b instanceof Date) {
370
+ const at = a instanceof Date ? a.getTime() : Date.parse(String(a))
371
+ const bt = b instanceof Date ? b.getTime() : Date.parse(String(b))
372
+ return at === bt
373
+ }
374
+ if (typeof a === 'boolean' || typeof b === 'boolean') {
375
+ // SQLite hands a boolean column back as `1`/`0` and MySQL as `1`/`0` on a
376
+ // `TINYINT(1)`, while the browser sends `true`/`false`. Comparing those as
377
+ // strings makes every boolean look edited.
378
+ return asBoolish(a) === asBoolish(b)
379
+ }
380
+ if (typeof a === 'object' || typeof b === 'object') {
381
+ // A cyclic object throws in `JSON.stringify`, and "could not be compared"
382
+ // has to mean "not equal" — the conservative direction, because it keeps
383
+ // the column in the statement rather than dropping an edit the user made.
384
+ //
385
+ // A bare try/catch rather than core's `Try`, and that is a constraint of
386
+ // this directory rather than a preference: see the module header.
387
+ try {
388
+ return JSON.stringify(a) === JSON.stringify(b)
389
+ } catch {
390
+ return false
391
+ }
392
+ }
393
+ return String(a) === String(b)
394
+ }
395
+
396
+ function asBoolish(value: unknown): boolean {
397
+ if (typeof value === 'boolean') return value
398
+ return TRUEISH.has(String(value).trim().toLowerCase())
399
+ }