@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.
- package/package.json +4 -4
- package/src/access.ts +188 -0
- package/src/client/api.ts +279 -0
- package/src/client/bulk.ts +357 -0
- package/src/client/cell.ts +139 -0
- package/src/client/confirm.ts +203 -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 +89 -0
- package/src/client/csv.ts +104 -0
- package/src/client/dom.ts +164 -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 +269 -0
- package/src/client/grid-body.ts +106 -0
- package/src/client/grid-header.ts +65 -0
- package/src/client/grid-rowbar.ts +64 -0
- package/src/client/grid.ts +468 -0
- package/src/client/meta.ts +185 -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 +205 -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 +234 -0
- package/src/client/tabs.ts +219 -0
- package/src/client/tabstrip.ts +127 -0
- package/src/client.ts +376 -160
- package/src/endpoints/common.ts +122 -0
- package/src/endpoints/graph.ts +148 -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 +186 -0
- package/src/shared/filters.ts +200 -0
- package/src/shared/plan.ts +173 -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/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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
} from '
|
|
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
|
|
26
|
-
let credential: string | undefined
|
|
28
|
+
let config: AccessConfig = {}
|
|
27
29
|
|
|
28
30
|
/**
|
|
29
|
-
* Test seam
|
|
30
|
-
*
|
|
31
|
-
*
|
|
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
|
|
34
|
-
|
|
35
|
+
export function __setTestAccess(next: AccessConfig): void {
|
|
36
|
+
config = next
|
|
35
37
|
}
|
|
36
38
|
|
|
37
|
-
export function
|
|
38
|
-
|
|
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
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
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.
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
//
|
|
147
|
-
//
|
|
148
|
-
|
|
149
|
-
return
|
|
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
|
-
|
|
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
|
+
}
|