@bakery-framework/plugin-db-explorer 2.0.0-alpha.4 → 2.0.0-alpha.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +42 -42
- package/src/policy.ts +45 -0
- package/src/preview.ts +53 -0
- package/src/setup.ts +63 -80
- 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/authorize.ts +0 -82
- package/src/endpoints.ts +0 -48
package/src/identity.ts
ADDED
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How a row is named.
|
|
3
|
+
*
|
|
4
|
+
* Every write endpoint addresses rows through this module and nothing else,
|
|
5
|
+
* because the three obvious alternatives are all broken and the explorer
|
|
6
|
+
* inherited none of them:
|
|
7
|
+
*
|
|
8
|
+
* - **`rowid`** (SQLite) is absent from a `WITHOUT ROWID` table and is not
|
|
9
|
+
* stable across a `VACUUM`.
|
|
10
|
+
* - **`ctid`** (Postgres) is the *physical* location of a tuple and moves on
|
|
11
|
+
* every UPDATE. Reading a page, editing two rows and writing the second by
|
|
12
|
+
* the ctid the read returned edits whatever now sits in that slot.
|
|
13
|
+
* - **`getSchema()`'s first `pk` column**, which the dashboard uses on MySQL.
|
|
14
|
+
* On a composite key that predicate matches every row sharing the first
|
|
15
|
+
* column, so one edit rewrites all of them. Silently.
|
|
16
|
+
*
|
|
17
|
+
* What replaces them is a declared key: a primary key, or failing that a unique
|
|
18
|
+
* index over columns that cannot be null. A table with neither cannot have one
|
|
19
|
+
* of its rows named at all, so it is read-only for everybody — a 409, not a
|
|
20
|
+
* best guess.
|
|
21
|
+
*
|
|
22
|
+
* ## The two traps this module exists to absorb
|
|
23
|
+
*
|
|
24
|
+
* `getConstraints()` camel-cases **both** the table key and every column key
|
|
25
|
+
* (`Case.camel`), while `getSchema()` and `getData()` speak raw database names.
|
|
26
|
+
* The bridge between them is here and only here; `Case.snake` is not the
|
|
27
|
+
* inverse of `Case.camel` (`SKU_code` → `sKUCode` → `s_k_u_code`), so raw names
|
|
28
|
+
* are carried through from `getSchema()` rather than reconstructed.
|
|
29
|
+
*
|
|
30
|
+
* And `getConstraints()` includes **views**, which carry a `_view` key. A view
|
|
31
|
+
* has no rows of its own to address, so it is always `none`.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { Case } from '@bakery-framework/core/utils'
|
|
35
|
+
import type { SQLAdapter } from '@bakery-framework/orm/adapters'
|
|
36
|
+
import { connection } from '@bakery-framework/orm/connection'
|
|
37
|
+
import type { ColumnKind, ColumnMeta } from './shared/coerce'
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The shapes the adapter reports, derived from its own signatures rather than
|
|
41
|
+
* imported: `@bakery-framework/orm` does not export `sync/types`, and adding a
|
|
42
|
+
* subpath to another package's export map to read three optional fields is a
|
|
43
|
+
* public-API decision this plugin does not get to make on its own.
|
|
44
|
+
*/
|
|
45
|
+
export type TableConstraints = Awaited<
|
|
46
|
+
ReturnType<SQLAdapter['getConstraints']>
|
|
47
|
+
>[string]
|
|
48
|
+
export type TableDetails = Awaited<ReturnType<SQLAdapter['getSchema']>>[number]
|
|
49
|
+
export type IndexEntry = Awaited<ReturnType<SQLAdapter['getIndexes']>>[string]
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* One column of `TableConstraints`, structurally.
|
|
53
|
+
*
|
|
54
|
+
* Spelled out because `TableConstraints` is an intersection of an index
|
|
55
|
+
* signature with `_view`/`_oldTable`/`_transform`, so indexing it does not
|
|
56
|
+
* narrow to the column type on its own.
|
|
57
|
+
*/
|
|
58
|
+
export interface ColumnConstraint {
|
|
59
|
+
type?: string
|
|
60
|
+
length?: number
|
|
61
|
+
_enum?: readonly string[]
|
|
62
|
+
primary?: boolean
|
|
63
|
+
autoIncrement?: boolean
|
|
64
|
+
nullable?: boolean
|
|
65
|
+
default?: unknown
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export type IdentityMode = 'pk' | 'unique' | 'none'
|
|
69
|
+
|
|
70
|
+
export interface Identity {
|
|
71
|
+
mode: IdentityMode
|
|
72
|
+
/** Raw database column names, in a stable order. Empty when `mode` is `none`. */
|
|
73
|
+
cols: string[]
|
|
74
|
+
/** Why there is no identity. Present only when `mode` is `none`. */
|
|
75
|
+
reason?: string
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface TableIntrospection {
|
|
79
|
+
/** The raw database table name. */
|
|
80
|
+
name: string
|
|
81
|
+
/** `getConstraints()[Case.camel(name)]`, camel-keyed, possibly `_view`. */
|
|
82
|
+
constraints: TableConstraints
|
|
83
|
+
/** This table's `getIndexes()` entries, with camel-cased column names. */
|
|
84
|
+
indexes: { name: string; type: string; cols: string[] }[]
|
|
85
|
+
/** Raw database column names, in `getSchema()` order. */
|
|
86
|
+
columns: string[]
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Can this identifier survive being written into a statement?
|
|
91
|
+
*
|
|
92
|
+
* `qId` — the single SQL identifier writer (convention 8) — snake-cases before
|
|
93
|
+
* quoting, so `qId('Orders')` emits `"orders"`. For every name the ORM created
|
|
94
|
+
* that is a no-op, because it snake-cases on the way in too. For a table some
|
|
95
|
+
* other tool created with a capital in it, it is a statement against a
|
|
96
|
+
* different object, and on a case-sensitive MySQL install that object may not
|
|
97
|
+
* exist or may be a different table entirely.
|
|
98
|
+
*
|
|
99
|
+
* So a name that does not round-trip is refused rather than quietly rewritten.
|
|
100
|
+
* The cost is that such a table is read-only in the explorer; the alternative
|
|
101
|
+
* is writing to a table the user did not name.
|
|
102
|
+
*/
|
|
103
|
+
export function isAddressable(name: string): boolean {
|
|
104
|
+
return Case.snake(name) === name
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Build the camel-key → raw-name map `getConstraints()` needs to be read through. */
|
|
108
|
+
function rawByCamel(columns: readonly string[]): Map<string, string> {
|
|
109
|
+
const map = new Map<string, string>()
|
|
110
|
+
for (const raw of columns) {
|
|
111
|
+
const camel = Case.camel(raw)
|
|
112
|
+
// First wins: two raw columns can camel-case alike (`user_id` and `userId`
|
|
113
|
+
// in the same table), and a later one must not silently take over the
|
|
114
|
+
// earlier one's constraints.
|
|
115
|
+
if (!map.has(camel)) map.set(camel, raw)
|
|
116
|
+
}
|
|
117
|
+
return map
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* A column key of `TableConstraints` that is really a column.
|
|
122
|
+
*
|
|
123
|
+
* `_view`, `_oldTable` and `_transform` share the same object. No real column
|
|
124
|
+
* can collide with them: `Case.camel` strips a leading underscore, so a column
|
|
125
|
+
* genuinely named `_view` is filed under `view`.
|
|
126
|
+
*/
|
|
127
|
+
function isColumnKey(key: string): boolean {
|
|
128
|
+
return !key.startsWith('_')
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const none = (reason: string): Identity => ({ mode: 'none', cols: [], reason })
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The identity of one table, in the order the brief fixes: declared primary
|
|
135
|
+
* key, else the narrowest all-NOT-NULL unique index, else nothing.
|
|
136
|
+
*/
|
|
137
|
+
export function describeIdentity(table: TableIntrospection): Identity {
|
|
138
|
+
if (!isAddressable(table.name)) {
|
|
139
|
+
return none(
|
|
140
|
+
`the table name ${table.name} is not addressable — ` +
|
|
141
|
+
'identifiers are snake-cased before they are quoted',
|
|
142
|
+
)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const constraints = table.constraints as Record<string, unknown>
|
|
146
|
+
if (typeof constraints._view === 'string') {
|
|
147
|
+
return none('a view has no rows of its own to address')
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const raw = rawByCamel(table.columns)
|
|
151
|
+
const column = (camel: string): ColumnConstraint | undefined =>
|
|
152
|
+
isColumnKey(camel) ? (constraints[camel] as ColumnConstraint) : undefined
|
|
153
|
+
|
|
154
|
+
// 1. Primary key. Composite is the normal case, not an exception — walked in
|
|
155
|
+
// `getSchema()` column order so a composite key has a stable spelling
|
|
156
|
+
// rather than whatever order the introspection query returned.
|
|
157
|
+
const primary = table.columns.filter(
|
|
158
|
+
name => column(Case.camel(name))?.primary === true,
|
|
159
|
+
)
|
|
160
|
+
if (primary.length) {
|
|
161
|
+
const unaddressable = primary.filter(c => !isAddressable(c))
|
|
162
|
+
if (unaddressable.length) {
|
|
163
|
+
return none(
|
|
164
|
+
`primary key columns are not addressable: ${unaddressable.join(', ')}`,
|
|
165
|
+
)
|
|
166
|
+
}
|
|
167
|
+
return { mode: 'pk', cols: primary }
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// 2. The narrowest unique index whose every column is declared NOT NULL.
|
|
171
|
+
// NULL is the whole reason for that condition: `NULL = NULL` is unknown,
|
|
172
|
+
// so a predicate over a nullable unique column matches no row, and an
|
|
173
|
+
// UPDATE that reports zero changes is indistinguishable from a conflict.
|
|
174
|
+
const candidates = table.indexes
|
|
175
|
+
.filter(index => index.type === 'unique')
|
|
176
|
+
.map(index => ({
|
|
177
|
+
name: index.name,
|
|
178
|
+
cols: index.cols.map(camel => raw.get(camel)),
|
|
179
|
+
}))
|
|
180
|
+
.filter(
|
|
181
|
+
(index): index is { name: string; cols: string[] } =>
|
|
182
|
+
index.cols.length > 0 &&
|
|
183
|
+
index.cols.every(
|
|
184
|
+
col =>
|
|
185
|
+
typeof col === 'string' &&
|
|
186
|
+
isAddressable(col) &&
|
|
187
|
+
// `nullable === false` explicitly, never `!nullable`. An adapter
|
|
188
|
+
// that reported nothing for a column would otherwise read as NOT
|
|
189
|
+
// NULL, which is the fail-open direction (convention 2).
|
|
190
|
+
column(Case.camel(col))?.nullable === false,
|
|
191
|
+
),
|
|
192
|
+
)
|
|
193
|
+
// Narrowest first; ties broken by index name so two equally narrow keys
|
|
194
|
+
// do not depend on introspection order.
|
|
195
|
+
.sort(
|
|
196
|
+
(a, b) => a.cols.length - b.cols.length || a.name.localeCompare(b.name),
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
const chosen = candidates[0]
|
|
200
|
+
if (chosen) return { mode: 'unique', cols: chosen.cols }
|
|
201
|
+
|
|
202
|
+
return none(
|
|
203
|
+
'no primary key and no unique index over NOT NULL columns — ' +
|
|
204
|
+
'there is no way to name one row of this table',
|
|
205
|
+
)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export interface ColumnFacts {
|
|
209
|
+
/** The raw database column name — what goes into a statement. */
|
|
210
|
+
name: string
|
|
211
|
+
/** The key `getConstraints()` filed it under. */
|
|
212
|
+
camel: string
|
|
213
|
+
/** The database's own type string, from `getSchema()`. */
|
|
214
|
+
sqlType: string
|
|
215
|
+
meta: ColumnMeta
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** One index, with its columns translated back to raw database names. */
|
|
219
|
+
export interface IndexFacts {
|
|
220
|
+
name: string
|
|
221
|
+
type: string
|
|
222
|
+
/** Raw column names where they resolve, the camel spelling where they do not. */
|
|
223
|
+
cols: string[]
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export interface TableFacts {
|
|
227
|
+
name: string
|
|
228
|
+
camel: string
|
|
229
|
+
isView: boolean
|
|
230
|
+
rowCount: number
|
|
231
|
+
columns: ColumnFacts[]
|
|
232
|
+
/** By raw column name. */
|
|
233
|
+
byName: Map<string, ColumnFacts>
|
|
234
|
+
identity: Identity
|
|
235
|
+
/**
|
|
236
|
+
* Declared indexes.
|
|
237
|
+
*
|
|
238
|
+
* Computed here already — `describeIdentity` walks them to find a usable
|
|
239
|
+
* unique key — and now carried out rather than discarded, because the
|
|
240
|
+
* Structure view shows them and there is no second endpoint that knows them.
|
|
241
|
+
*/
|
|
242
|
+
indexes: IndexFacts[]
|
|
243
|
+
/**
|
|
244
|
+
* The first text column that is not part of the identity — what a foreign-key
|
|
245
|
+
* reference shows instead of a bare id. `null` when the table has none.
|
|
246
|
+
*/
|
|
247
|
+
label: string | null
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Genuine date/time column types, as the three dialects spell them. */
|
|
251
|
+
const RX_DATE_TYPE = /^(date|datetime|timestamp)/i
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* What kind of value a column holds.
|
|
255
|
+
*
|
|
256
|
+
* `getConstraints()` is the authority, with one exception it cannot express:
|
|
257
|
+
* `ColumnType` has no date member, so every adapter maps a real `DATE` or
|
|
258
|
+
* `TIMESTAMP` to `string` (MySQL explicitly, Postgres by falling through). The
|
|
259
|
+
* raw SQL type is the only place that distinction survives, and it matters —
|
|
260
|
+
* `''` into a text column is an empty string and into a timestamp is an error.
|
|
261
|
+
*/
|
|
262
|
+
function kindOf(declared: string | undefined, sqlType: string): ColumnKind {
|
|
263
|
+
const known: ColumnKind[] = [
|
|
264
|
+
'integer',
|
|
265
|
+
'number',
|
|
266
|
+
'bigint',
|
|
267
|
+
'string',
|
|
268
|
+
'boolean',
|
|
269
|
+
'json',
|
|
270
|
+
'buffer',
|
|
271
|
+
]
|
|
272
|
+
const kind = known.find(k => k === declared) ?? 'string'
|
|
273
|
+
if (kind === 'string' && RX_DATE_TYPE.test(sqlType.trim())) return 'date'
|
|
274
|
+
return kind
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export function metaOf(
|
|
278
|
+
constraint: ColumnConstraint | undefined,
|
|
279
|
+
schemaColumn: TableDetails['columns'][number],
|
|
280
|
+
): ColumnMeta {
|
|
281
|
+
return {
|
|
282
|
+
kind: kindOf(constraint?.type, schemaColumn.type ?? ''),
|
|
283
|
+
// `getConstraints()` when it says anything, `getSchema()`'s NOT NULL flag
|
|
284
|
+
// otherwise. Both are the database's own answer; the first is the richer
|
|
285
|
+
// one and the second is never absent.
|
|
286
|
+
nullable: constraint?.nullable ?? !schemaColumn.notnull,
|
|
287
|
+
length: constraint?.length,
|
|
288
|
+
enum: constraint?._enum,
|
|
289
|
+
// **Not `'default' in constraint`.** That reads as the careful choice —
|
|
290
|
+
// `DEFAULT NULL` is a real default and is filed as `default: null`, so the
|
|
291
|
+
// key's presence looks like the honest test. It is not: `parseConstraints`
|
|
292
|
+
// writes the key on **every** column, defaulted or not, so `in` answered
|
|
293
|
+
// `true` for all of them.
|
|
294
|
+
//
|
|
295
|
+
// That is not cosmetic. `omittableOnInsert` is
|
|
296
|
+
// `autoIncrement || hasDefault || nullable`, so an always-true `hasDefault`
|
|
297
|
+
// makes every column omittable — and the insert dialog would happily leave
|
|
298
|
+
// out a NOT NULL column with no default, to be refused by the database
|
|
299
|
+
// rather than by the form that knew.
|
|
300
|
+
//
|
|
301
|
+
// A column genuinely declared `DEFAULT NULL` reads as having none here. It
|
|
302
|
+
// loses nothing: such a column is nullable, and `nullable` is the next term
|
|
303
|
+
// in that same expression.
|
|
304
|
+
hasDefault: constraint?.default !== null && constraint?.default !== undefined,
|
|
305
|
+
primary: constraint?.primary ?? schemaColumn.pk,
|
|
306
|
+
autoIncrement: constraint?.autoIncrement,
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Every table the connection has, with its identity resolved.
|
|
312
|
+
*
|
|
313
|
+
* Three round trips, taken per request rather than cached. Convention 6 forbids
|
|
314
|
+
* an unbounded module cache, and a bounded one would be worse than none here:
|
|
315
|
+
* the wrong entry is not a slow response, it is a write addressed by a key the
|
|
316
|
+
* table no longer has. Schema introspection is also exactly what the explorer's
|
|
317
|
+
* own `/api/_db/schema` call already costs, so a write pays what a page load
|
|
318
|
+
* pays.
|
|
319
|
+
*/
|
|
320
|
+
export async function introspect(): Promise<Map<string, TableFacts>> {
|
|
321
|
+
const [schema, constraints, indexes] = await Promise.all([
|
|
322
|
+
connection.getSchema(),
|
|
323
|
+
connection.getConstraints(),
|
|
324
|
+
connection.getIndexes(),
|
|
325
|
+
])
|
|
326
|
+
|
|
327
|
+
const byTable = new Map<string, TableFacts>()
|
|
328
|
+
for (const table of schema) {
|
|
329
|
+
const camel = Case.camel(table.name)
|
|
330
|
+
const tableConstraints = (constraints[camel] ?? {}) as TableConstraints
|
|
331
|
+
const raw = tableConstraints as Record<string, unknown>
|
|
332
|
+
|
|
333
|
+
const tableIndexes = Object.entries(indexes)
|
|
334
|
+
.filter(([, index]) => index.table === camel)
|
|
335
|
+
.map(([name, index]) => ({
|
|
336
|
+
name,
|
|
337
|
+
type: index.type,
|
|
338
|
+
cols: index.cols,
|
|
339
|
+
}))
|
|
340
|
+
|
|
341
|
+
const identity = describeIdentity({
|
|
342
|
+
name: table.name,
|
|
343
|
+
constraints: tableConstraints,
|
|
344
|
+
indexes: tableIndexes,
|
|
345
|
+
columns: table.columns.map(c => c.name),
|
|
346
|
+
})
|
|
347
|
+
|
|
348
|
+
const columns = table.columns.map(column => {
|
|
349
|
+
const columnCamel = Case.camel(column.name)
|
|
350
|
+
const constraint = isColumnKey(columnCamel)
|
|
351
|
+
? (raw[columnCamel] as ColumnConstraint | undefined)
|
|
352
|
+
: undefined
|
|
353
|
+
return {
|
|
354
|
+
name: column.name,
|
|
355
|
+
camel: columnCamel,
|
|
356
|
+
sqlType: column.type ?? '',
|
|
357
|
+
meta: metaOf(constraint, column),
|
|
358
|
+
}
|
|
359
|
+
})
|
|
360
|
+
|
|
361
|
+
const identityCols = new Set(identity.cols)
|
|
362
|
+
const label =
|
|
363
|
+
columns.find(c => c.meta.kind === 'string' && !identityCols.has(c.name))
|
|
364
|
+
?.name ?? null
|
|
365
|
+
|
|
366
|
+
// `getIndexes()` camel-cases its column names like `getConstraints()` does.
|
|
367
|
+
// Translating back through the same map `describeIdentity` uses keeps one
|
|
368
|
+
// spelling on screen; an unresolvable name is shown as it came rather than
|
|
369
|
+
// dropped, so a mismatch is visible instead of silently missing a column.
|
|
370
|
+
const rawColumns = rawByCamel(table.columns.map(c => c.name))
|
|
371
|
+
const tableIndexFacts = tableIndexes.map(index => ({
|
|
372
|
+
name: index.name,
|
|
373
|
+
type: index.type,
|
|
374
|
+
cols: index.cols.map(col => rawColumns.get(col) ?? col),
|
|
375
|
+
}))
|
|
376
|
+
|
|
377
|
+
byTable.set(table.name, {
|
|
378
|
+
name: table.name,
|
|
379
|
+
camel,
|
|
380
|
+
isView: typeof raw._view === 'string',
|
|
381
|
+
rowCount: table.rowCount,
|
|
382
|
+
columns,
|
|
383
|
+
byName: new Map(columns.map(c => [c.name, c])),
|
|
384
|
+
identity,
|
|
385
|
+
indexes: tableIndexFacts,
|
|
386
|
+
label,
|
|
387
|
+
})
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
return byTable
|
|
391
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,53 +1,56 @@
|
|
|
1
1
|
import { definePlugin } from '@bakery-framework/core/plugins'
|
|
2
|
-
import type {
|
|
2
|
+
import type { AccessConfig } from './access'
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
/**
|
|
5
|
+
* Part of this plugin's public surface.
|
|
6
|
+
*
|
|
7
|
+
* These are the explorer's own rather than core's `AuthorizeFn`, because the
|
|
8
|
+
* explorer asks a different question. The dashboard and analytics need to know
|
|
9
|
+
* *whether* a request is admitted; the explorer edits rows, so it needs to know
|
|
10
|
+
* *what* the request may do. A boolean cannot carry that.
|
|
11
|
+
*/
|
|
12
|
+
export type { Access, AccessFn, ExplorerUser, ExplorerUsers } from './access'
|
|
5
13
|
|
|
6
|
-
export interface DbExplorerPluginOptions {
|
|
14
|
+
export interface DbExplorerPluginOptions extends AccessConfig {
|
|
7
15
|
/**
|
|
8
16
|
* Register the explorer. Defaults to true; set false to keep it out of a
|
|
9
17
|
* build entirely.
|
|
10
18
|
*/
|
|
11
19
|
enabled?: boolean
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* Decide whether a request may browse the database. Return true to allow.
|
|
15
|
-
*
|
|
16
|
-
* The explorer authenticates nobody itself — the application does, because
|
|
17
|
-
* it already knows who its users are:
|
|
18
|
-
*
|
|
19
|
-
* ```ts
|
|
20
|
-
* dbExplorerPlugin({
|
|
21
|
-
* authorize: req => req.session.get('role') === 'admin',
|
|
22
|
-
* })
|
|
23
|
-
* ```
|
|
24
|
-
*
|
|
25
|
-
* Omitted, access is loopback-only in development and denied in
|
|
26
|
-
* production, so an unconfigured explorer is never exposed.
|
|
27
|
-
*/
|
|
28
|
-
authorize?: AuthorizeFn
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* A shared access key, typically from the environment:
|
|
32
|
-
*
|
|
33
|
-
* ```ts
|
|
34
|
-
* dbExplorerPlugin({ credential: import.meta.env.DB_EXPLORER_KEY })
|
|
35
|
-
* ```
|
|
36
|
-
*
|
|
37
|
-
* Presented as `Authorization: Bearer`, an `x-db-key` header, or a
|
|
38
|
-
* one-time `?key=` query for a browser (stored client-side, stripped from
|
|
39
|
-
* the URL). Checked in constant time. Unset or empty means this path is
|
|
40
|
-
* off — it never means open. Composes with `authorize`: either admits.
|
|
41
|
-
*/
|
|
42
|
-
credential?: string
|
|
43
20
|
}
|
|
44
21
|
|
|
45
22
|
/**
|
|
46
|
-
* A
|
|
23
|
+
* A database browser and editor at `/_db`: table list, rows, paging, sorting,
|
|
24
|
+
* inline editing, foreign-key navigation and CSV import.
|
|
25
|
+
*
|
|
26
|
+
* **No raw SQL and no DDL.** That is structural, not a mode: there is no
|
|
27
|
+
* endpoint that runs a statement you supply, and none that creates, drops or
|
|
28
|
+
* alters a table. What CRUD changed is row data. Where the dashboard gated its
|
|
29
|
+
* write paths behind an environment flag, the explorer still has no path for
|
|
30
|
+
* the things it refuses.
|
|
31
|
+
*
|
|
32
|
+
* Nobody is admitted until an application says who may come in, and access is
|
|
33
|
+
* a level rather than a yes:
|
|
34
|
+
*
|
|
35
|
+
* ```ts
|
|
36
|
+
* dbExplorerPlugin({
|
|
37
|
+
* users: {
|
|
38
|
+
* ops: { credential: process.env.OPS_KEY!, access: 'write' },
|
|
39
|
+
* oncall: { credential: process.env.ONCALL_KEY!, access: 'read' },
|
|
40
|
+
* },
|
|
41
|
+
* authorize: req => (req.session.get('role') === 'admin' ? 'write' : false),
|
|
42
|
+
* })
|
|
43
|
+
* ```
|
|
44
|
+
*
|
|
45
|
+
* Either door admits and the higher level wins — a session admin presenting a
|
|
46
|
+
* read-only key is still an admin. With neither configured the explorer admits
|
|
47
|
+
* nobody, which is the same default it had when it was read-only, and the
|
|
48
|
+
* reason there is no `writes: true` flag to leave set by accident.
|
|
47
49
|
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
50
|
+
* A `read` caller gets the grid with no edit affordances and a 403 on every
|
|
51
|
+
* write endpoint. A table with no primary key or unique index is read-only for
|
|
52
|
+
* everyone: there is no way to name one of its rows, so there is no way to
|
|
53
|
+
* change one safely.
|
|
51
54
|
*/
|
|
52
55
|
export default function dbExplorerPlugin(
|
|
53
56
|
options: DbExplorerPluginOptions = {},
|
|
@@ -59,10 +62,7 @@ export default function dbExplorerPlugin(
|
|
|
59
62
|
async setup() {
|
|
60
63
|
if (!enabled) return
|
|
61
64
|
const { setupExplorer } = await import('./setup')
|
|
62
|
-
setupExplorer({
|
|
63
|
-
authorize: options.authorize,
|
|
64
|
-
credential: options.credential,
|
|
65
|
-
})
|
|
65
|
+
setupExplorer({ users: options.users, authorize: options.authorize })
|
|
66
66
|
},
|
|
67
67
|
})
|
|
68
68
|
}
|
package/src/policy.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How much one request may do.
|
|
3
|
+
*
|
|
4
|
+
* These are not rate limits and not tuning knobs — they are the size at which a
|
|
5
|
+
* request stops being an edit and starts being a migration. The explorer has no
|
|
6
|
+
* raw-SQL endpoint precisely so that nothing it exposes can rewrite a table in
|
|
7
|
+
* one call; a `rows` array with no ceiling would put that back, one row at a
|
|
8
|
+
* time.
|
|
9
|
+
*
|
|
10
|
+
* **Over a bound is a 413 with nothing executed.** Not a truncation, and not a
|
|
11
|
+
* partial apply: a caller who sent 5,000 rows and got "1,000 inserted" has no
|
|
12
|
+
* way to know which 1,000, and the retry duplicates them.
|
|
13
|
+
*
|
|
14
|
+
* The numbers are deliberately round rather than derived. The one real
|
|
15
|
+
* constraint — the ~32,766 bound parameters a statement may carry — is already
|
|
16
|
+
* handled below this layer, by `DB.Insert`'s batching.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export const LIMITS = {
|
|
20
|
+
/** Rows in one `POST /api/_db/rows`. */
|
|
21
|
+
insertRows: 1000,
|
|
22
|
+
/** Edits in one `POST /api/_db/rows/bulk`. */
|
|
23
|
+
bulkEdits: 1000,
|
|
24
|
+
/** Keys in one `DELETE /api/_db/rows`. */
|
|
25
|
+
deleteKeys: 1000,
|
|
26
|
+
/** Rows in one `POST /api/_db/import` — a spreadsheet, not an edit. */
|
|
27
|
+
csvRows: 50_000,
|
|
28
|
+
/** Foreign-key targets resolved in one `POST /api/_db/lookup`. */
|
|
29
|
+
lookupRefs: 200,
|
|
30
|
+
} as const
|
|
31
|
+
|
|
32
|
+
export type LimitName = keyof typeof LIMITS
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The 413 message for a request that is too big, or `null` if it fits.
|
|
36
|
+
*
|
|
37
|
+
* Returns the rejection rather than throwing, and returns it for an
|
|
38
|
+
* indeterminate count as well (a `keys` that is not an array reads as `NaN`) —
|
|
39
|
+
* convention 2's shape, applied to a bound rather than to an identity.
|
|
40
|
+
*/
|
|
41
|
+
export function overLimit(limit: LimitName, count: number): string | null {
|
|
42
|
+
const max = LIMITS[limit]
|
|
43
|
+
if (Number.isFinite(count) && count <= max) return null
|
|
44
|
+
return `too many: ${limit} is limited to ${max} per request, got ${count}`
|
|
45
|
+
}
|
package/src/preview.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rolling a transaction back on purpose, and getting an answer out of it.
|
|
3
|
+
*
|
|
4
|
+
* **This is a workaround for a gap in the ORM, and it should be read as one.**
|
|
5
|
+
* `SQLAdapter.transaction()` commits when the callback returns and rolls back
|
|
6
|
+
* when it throws — there is no third outcome, so there is no way to say "undo
|
|
7
|
+
* this, and here is what it would have done". A dry run needs exactly that: the
|
|
8
|
+
* statements have to actually execute (otherwise the report is a guess about
|
|
9
|
+
* constraints the database has not been asked about), and then not stick.
|
|
10
|
+
*
|
|
11
|
+
* So the report rides out on the exception. The transaction rolls back because
|
|
12
|
+
* the callback threw, `Try.return` catches the signal outside and turns it into
|
|
13
|
+
* an ordinary response, and anything that is **not** this class is rethrown —
|
|
14
|
+
* which is the part that has to stay right. A `catch` that swallowed a real
|
|
15
|
+
* error here would report a failed write as a successful preview.
|
|
16
|
+
*
|
|
17
|
+
* The honest fix is a `transaction()` that takes a rollback decision from its
|
|
18
|
+
* callback's return value. When the ORM grows one, this module goes away and
|
|
19
|
+
* the four endpoints that import it lose an `if`.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A deliberate rollback carrying its report.
|
|
24
|
+
*
|
|
25
|
+
* `status` because two different outcomes need the same mechanism: a dry run
|
|
26
|
+
* answers 200 with what *would* have happened, and a bulk edit that hit an
|
|
27
|
+
* optimistic-concurrency conflict answers 409 with what did — and both have to
|
|
28
|
+
* leave the database untouched, so both have to leave through a throw.
|
|
29
|
+
*/
|
|
30
|
+
export class RollbackSignal<T = unknown> extends Error {
|
|
31
|
+
constructor(
|
|
32
|
+
readonly report: T,
|
|
33
|
+
readonly status: number,
|
|
34
|
+
message: string,
|
|
35
|
+
) {
|
|
36
|
+
super(message)
|
|
37
|
+
this.name = 'RollbackSignal'
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** A dry run: nothing kept, 200 with the report. */
|
|
42
|
+
export function previewRollback<T>(report: T): never {
|
|
43
|
+
throw new RollbackSignal(report, 200, 'dry run — rolled back')
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** A conflict: nothing kept, 409 with the report. */
|
|
47
|
+
export function conflictRollback<T>(report: T): never {
|
|
48
|
+
throw new RollbackSignal(report, 409, 'conflict — rolled back')
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function isRollbackSignal(error: unknown): error is RollbackSignal {
|
|
52
|
+
return error instanceof RollbackSignal
|
|
53
|
+
}
|