@bakery-framework/plugin-db-explorer 2.0.0-alpha.9 → 2.0.0-rc.1
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 +5 -5
- package/src/access.ts +53 -17
- package/src/client/api.ts +48 -6
- package/src/client/bulk.ts +31 -7
- package/src/client/cell.ts +5 -5
- package/src/client/confirm.ts +11 -12
- package/src/client/csv-commit.ts +25 -5
- package/src/client/csv-map.ts +7 -7
- package/src/client/csv-model.ts +61 -8
- package/src/client/csv-pick.ts +1 -1
- package/src/client/csv-preview.ts +2 -2
- package/src/client/csv.ts +2 -2
- package/src/client/dom.ts +3 -5
- package/src/client/edit-session.ts +7 -7
- package/src/client/editors.ts +5 -5
- package/src/client/filter-builder.ts +6 -6
- package/src/client/fk.ts +9 -9
- package/src/client/grid-body.ts +4 -4
- package/src/client/grid-header.ts +2 -2
- package/src/client/grid-rowbar.ts +1 -1
- package/src/client/grid.ts +8 -9
- package/src/client/meta.ts +6 -6
- package/src/client/page.ts +4 -4
- package/src/client/panel.ts +8 -8
- package/src/client/relations.ts +4 -4
- package/src/client/save.ts +4 -4
- package/src/client/sidebar.ts +6 -6
- package/src/client/state.ts +4 -4
- package/src/client/statusbar.ts +6 -6
- package/src/client/structure.ts +8 -8
- package/src/client/tabs.ts +10 -10
- package/src/client/tabstrip.ts +3 -3
- package/src/client.ts +17 -17
- package/src/endpoints/common.ts +25 -6
- package/src/endpoints/graph.ts +5 -5
- package/src/endpoints/import.ts +5 -5
- package/src/endpoints/read.ts +70 -10
- package/src/endpoints/rows.ts +219 -14
- package/src/identity.ts +122 -52
- package/src/index.ts +1 -1
- package/src/policy.ts +4 -4
- package/src/preview.ts +5 -5
- package/src/setup.ts +16 -10
- package/src/shared/coerce.ts +21 -22
- package/src/shared/csv.ts +5 -5
- package/src/shared/filters.ts +6 -6
- package/src/shared/plan.ts +7 -7
- package/src/shell.ts +3 -3
- package/src/validate.ts +10 -10
package/src/identity.ts
CHANGED
|
@@ -16,16 +16,22 @@
|
|
|
16
16
|
*
|
|
17
17
|
* What replaces them is a declared key: a primary key, or failing that a unique
|
|
18
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
|
|
19
|
+
* of its rows named at all, so it is read-only for everybody: a 409, not a
|
|
20
20
|
* best guess.
|
|
21
21
|
*
|
|
22
22
|
* ## The two traps this module exists to absorb
|
|
23
23
|
*
|
|
24
24
|
* `getConstraints()` camel-cases **both** the table key and every column key
|
|
25
25
|
* (`Case.camel`), while `getSchema()` and `getData()` speak raw database names.
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
* are carried through from `getSchema()
|
|
26
|
+
* `Case.snake` is not the inverse of `Case.camel` (`SKU_code` → `sKUCode` →
|
|
27
|
+
* `s_k_u_code`), so raw names cannot be recovered from camel ones. For columns
|
|
28
|
+
* they are carried through from `getSchema()`; for index columns the adapters
|
|
29
|
+
* now carry them too (`rawCols` on `getIndexes()` entries), which retired the
|
|
30
|
+
* camel→raw map this module used to rebuild. That map was first-wins over
|
|
31
|
+
* collisions (a table holding both `user_id` and `userId` resolved an index
|
|
32
|
+
* over the second to the first, a predicate over the wrong column), and the
|
|
33
|
+
* adapter, which had the real names all along, is the only place that cannot
|
|
34
|
+
* happen.
|
|
29
35
|
*
|
|
30
36
|
* And `getConstraints()` includes **views**, which carry a `_view` key. A view
|
|
31
37
|
* has no rows of its own to address, so it is always `none`.
|
|
@@ -80,8 +86,11 @@ export interface TableIntrospection {
|
|
|
80
86
|
name: string
|
|
81
87
|
/** `getConstraints()[Case.camel(name)]`, camel-keyed, possibly `_view`. */
|
|
82
88
|
constraints: TableConstraints
|
|
83
|
-
/**
|
|
84
|
-
|
|
89
|
+
/**
|
|
90
|
+
* This table's `getIndexes()` entries: `cols` camel-cased, `rawCols` the
|
|
91
|
+
* database's own spelling, aligned by position.
|
|
92
|
+
*/
|
|
93
|
+
indexes: { name: string; type: string; cols: string[]; rawCols?: string[] }[]
|
|
85
94
|
/** Raw database column names, in `getSchema()` order. */
|
|
86
95
|
columns: string[]
|
|
87
96
|
}
|
|
@@ -89,7 +98,7 @@ export interface TableIntrospection {
|
|
|
89
98
|
/**
|
|
90
99
|
* Can this identifier survive being written into a statement?
|
|
91
100
|
*
|
|
92
|
-
* `qId`
|
|
101
|
+
* `qId` (the single SQL identifier writer (convention 8)) snake-cases before
|
|
93
102
|
* quoting, so `qId('Orders')` emits `"orders"`. For every name the ORM created
|
|
94
103
|
* that is a no-op, because it snake-cases on the way in too. For a table some
|
|
95
104
|
* other tool created with a capital in it, it is a statement against a
|
|
@@ -104,19 +113,6 @@ export function isAddressable(name: string): boolean {
|
|
|
104
113
|
return Case.snake(name) === name
|
|
105
114
|
}
|
|
106
115
|
|
|
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
116
|
/**
|
|
121
117
|
* A column key of `TableConstraints` that is really a column.
|
|
122
118
|
*
|
|
@@ -137,7 +133,7 @@ const none = (reason: string): Identity => ({ mode: 'none', cols: [], reason })
|
|
|
137
133
|
export function describeIdentity(table: TableIntrospection): Identity {
|
|
138
134
|
if (!isAddressable(table.name)) {
|
|
139
135
|
return none(
|
|
140
|
-
`the table name ${table.name} is not addressable
|
|
136
|
+
`the table name ${table.name} is not addressable: ` +
|
|
141
137
|
'identifiers are snake-cased before they are quoted',
|
|
142
138
|
)
|
|
143
139
|
}
|
|
@@ -147,11 +143,10 @@ export function describeIdentity(table: TableIntrospection): Identity {
|
|
|
147
143
|
return none('a view has no rows of its own to address')
|
|
148
144
|
}
|
|
149
145
|
|
|
150
|
-
const raw = rawByCamel(table.columns)
|
|
151
146
|
const column = (camel: string): ColumnConstraint | undefined =>
|
|
152
147
|
isColumnKey(camel) ? (constraints[camel] as ColumnConstraint) : undefined
|
|
153
148
|
|
|
154
|
-
// 1. Primary key. Composite is the normal case, not an exception
|
|
149
|
+
// 1. Primary key. Composite is the normal case, not an exception: walked in
|
|
155
150
|
// `getSchema()` column order so a composite key has a stable spelling
|
|
156
151
|
// rather than whatever order the introspection query returned.
|
|
157
152
|
const primary = table.columns.filter(
|
|
@@ -175,19 +170,27 @@ export function describeIdentity(table: TableIntrospection): Identity {
|
|
|
175
170
|
.filter(index => index.type === 'unique')
|
|
176
171
|
.map(index => ({
|
|
177
172
|
name: index.name,
|
|
178
|
-
|
|
173
|
+
// The adapter's own raw spelling, aligned with `cols` by position: the
|
|
174
|
+
// camel spelling is kept alongside because the *constraints* are keyed by
|
|
175
|
+
// it. An entry with no `rawCols` (a TS-declared index reaching here
|
|
176
|
+
// through some future path) yields `undefined` cells and is filtered out
|
|
177
|
+
// below rather than guessed at.
|
|
178
|
+
cols: index.cols.map((camel, i) => ({
|
|
179
|
+
camel,
|
|
180
|
+
raw: index.rawCols?.[i],
|
|
181
|
+
})),
|
|
179
182
|
}))
|
|
180
183
|
.filter(
|
|
181
|
-
(index): index is { name: string; cols: string[] } =>
|
|
184
|
+
(index): index is { name: string; cols: { camel: string; raw: string }[] } =>
|
|
182
185
|
index.cols.length > 0 &&
|
|
183
186
|
index.cols.every(
|
|
184
187
|
col =>
|
|
185
|
-
typeof col === 'string' &&
|
|
186
|
-
isAddressable(col) &&
|
|
188
|
+
typeof col.raw === 'string' &&
|
|
189
|
+
isAddressable(col.raw) &&
|
|
187
190
|
// `nullable === false` explicitly, never `!nullable`. An adapter
|
|
188
191
|
// that reported nothing for a column would otherwise read as NOT
|
|
189
192
|
// NULL, which is the fail-open direction (convention 2).
|
|
190
|
-
column(
|
|
193
|
+
column(col.camel)?.nullable === false,
|
|
191
194
|
),
|
|
192
195
|
)
|
|
193
196
|
// Narrowest first; ties broken by index name so two equally narrow keys
|
|
@@ -197,16 +200,16 @@ export function describeIdentity(table: TableIntrospection): Identity {
|
|
|
197
200
|
)
|
|
198
201
|
|
|
199
202
|
const chosen = candidates[0]
|
|
200
|
-
if (chosen) return { mode: 'unique', cols: chosen.cols }
|
|
203
|
+
if (chosen) return { mode: 'unique', cols: chosen.cols.map(c => c.raw) }
|
|
201
204
|
|
|
202
205
|
return none(
|
|
203
|
-
'no primary key and no unique index over NOT NULL columns
|
|
206
|
+
'no primary key and no unique index over NOT NULL columns: ' +
|
|
204
207
|
'there is no way to name one row of this table',
|
|
205
208
|
)
|
|
206
209
|
}
|
|
207
210
|
|
|
208
211
|
export interface ColumnFacts {
|
|
209
|
-
/** The raw database column name
|
|
212
|
+
/** The raw database column name: what goes into a statement. */
|
|
210
213
|
name: string
|
|
211
214
|
/** The key `getConstraints()` filed it under. */
|
|
212
215
|
camel: string
|
|
@@ -227,7 +230,8 @@ export interface TableFacts {
|
|
|
227
230
|
name: string
|
|
228
231
|
camel: string
|
|
229
232
|
isView: boolean
|
|
230
|
-
|
|
233
|
+
/** `null` unless `introspect({ rowCounts: true })` was asked. See below. */
|
|
234
|
+
rowCount: number | null
|
|
231
235
|
columns: ColumnFacts[]
|
|
232
236
|
/** By raw column name. */
|
|
233
237
|
byName: Map<string, ColumnFacts>
|
|
@@ -235,13 +239,13 @@ export interface TableFacts {
|
|
|
235
239
|
/**
|
|
236
240
|
* Declared indexes.
|
|
237
241
|
*
|
|
238
|
-
* Computed here already
|
|
239
|
-
* unique key
|
|
242
|
+
* Computed here already (`describeIdentity` walks them to find a usable
|
|
243
|
+
* unique key), and now carried out rather than discarded, because the
|
|
240
244
|
* Structure view shows them and there is no second endpoint that knows them.
|
|
241
245
|
*/
|
|
242
246
|
indexes: IndexFacts[]
|
|
243
247
|
/**
|
|
244
|
-
* The first text column that is not part of the identity
|
|
248
|
+
* The first text column that is not part of the identity: what a foreign-key
|
|
245
249
|
* reference shows instead of a bare id. `null` when the table has none.
|
|
246
250
|
*/
|
|
247
251
|
label: string | null
|
|
@@ -256,7 +260,7 @@ const RX_DATE_TYPE = /^(date|datetime|timestamp)/i
|
|
|
256
260
|
* `getConstraints()` is the authority, with one exception it cannot express:
|
|
257
261
|
* `ColumnType` has no date member, so every adapter maps a real `DATE` or
|
|
258
262
|
* `TIMESTAMP` to `string` (MySQL explicitly, Postgres by falling through). The
|
|
259
|
-
* raw SQL type is the only place that distinction survives, and it matters
|
|
263
|
+
* raw SQL type is the only place that distinction survives, and it matters:
|
|
260
264
|
* `''` into a text column is an empty string and into a timestamp is an error.
|
|
261
265
|
*/
|
|
262
266
|
function kindOf(declared: string | undefined, sqlType: string): ColumnKind {
|
|
@@ -286,7 +290,7 @@ export function metaOf(
|
|
|
286
290
|
nullable: constraint?.nullable ?? !schemaColumn.notnull,
|
|
287
291
|
length: constraint?.length,
|
|
288
292
|
enum: constraint?._enum,
|
|
289
|
-
// **Not `'default' in constraint`.** That reads as the careful choice
|
|
293
|
+
// **Not `'default' in constraint`.** That reads as the careful choice:
|
|
290
294
|
// `DEFAULT NULL` is a real default and is filed as `default: null`, so the
|
|
291
295
|
// key's presence looks like the honest test. It is not: `parseConstraints`
|
|
292
296
|
// writes the key on **every** column, defaulted or not, so `in` answered
|
|
@@ -294,7 +298,7 @@ export function metaOf(
|
|
|
294
298
|
//
|
|
295
299
|
// That is not cosmetic. `omittableOnInsert` is
|
|
296
300
|
// `autoIncrement || hasDefault || nullable`, so an always-true `hasDefault`
|
|
297
|
-
// makes every column omittable
|
|
301
|
+
// makes every column omittable, and the insert dialog would happily leave
|
|
298
302
|
// out a NOT NULL column with no default, to be refused by the database
|
|
299
303
|
// rather than by the form that knew.
|
|
300
304
|
//
|
|
@@ -307,19 +311,82 @@ export function metaOf(
|
|
|
307
311
|
}
|
|
308
312
|
}
|
|
309
313
|
|
|
314
|
+
/**
|
|
315
|
+
* One entry, held only while the schema it describes is still the live one.
|
|
316
|
+
*
|
|
317
|
+
* Convention 6 forbids an unbounded module cache; this is a single slot, so it
|
|
318
|
+
* is bounded by construction. What made a cache unsafe here was never its size
|
|
319
|
+
* - the note this replaces said so, and it was right at the time: "the wrong
|
|
320
|
+
* entry is not a slow response, it is a write addressed by a key the table no
|
|
321
|
+
* longer has." Staleness, not memory.
|
|
322
|
+
*
|
|
323
|
+
* `schemaFingerprint()` closes exactly that. The entry is used only when the
|
|
324
|
+
* adapter reports the same schema version it was built under, so an entry
|
|
325
|
+
* describing a dropped column cannot be handed to a write - the version moved
|
|
326
|
+
* when the column was dropped.
|
|
327
|
+
*
|
|
328
|
+
* Keyed on the whole fingerprint string rather than a bare counter, because
|
|
329
|
+
* the adapter prefixes it with the driver and the file: two SQLite databases
|
|
330
|
+
* both at version 3 must not share an entry.
|
|
331
|
+
*/
|
|
332
|
+
let cached: {
|
|
333
|
+
fingerprint: string
|
|
334
|
+
facts: Map<string, TableFacts>
|
|
335
|
+
} | null = null
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Test seam (convention 9). A cache with no way to clear it makes every test
|
|
339
|
+
* after the first one depend on what the first one did.
|
|
340
|
+
*/
|
|
341
|
+
export function __resetIntrospectCache() {
|
|
342
|
+
cached = null
|
|
343
|
+
}
|
|
344
|
+
|
|
310
345
|
/**
|
|
311
346
|
* Every table the connection has, with its identity resolved.
|
|
312
347
|
*
|
|
313
|
-
* Three round trips,
|
|
314
|
-
*
|
|
315
|
-
* the
|
|
316
|
-
*
|
|
317
|
-
*
|
|
318
|
-
* pays
|
|
348
|
+
* Three round trips, and for a 50-table SQLite database that is **250
|
|
349
|
+
* statements and 8.28 ms** - paid by every write, every foreign-key hover and
|
|
350
|
+
* the graph endpoint. On a network dialect the same shape is roughly 52
|
|
351
|
+
* sequential round trips per write.
|
|
352
|
+
*
|
|
353
|
+
* Asking whether it is still valid is 10.4 us, so the cache pays for itself
|
|
354
|
+
* 795 times over on a hit and costs one extra statement on a miss.
|
|
355
|
+
*
|
|
356
|
+
* **Row counts are never cached**, and that is the load-bearing exception.
|
|
357
|
+
* `schema_version` does not move for an `INSERT` - which is exactly what makes
|
|
358
|
+
* it a good key for schema, and exactly what makes it a wrong key for a
|
|
359
|
+
* `COUNT(*)`. A cached count would be silently wrong for as long as nobody
|
|
360
|
+
* changed the schema. Asking for counts skips the cache in both directions:
|
|
361
|
+
* it neither reads an entry nor writes one.
|
|
319
362
|
*/
|
|
320
|
-
export async function introspect(
|
|
363
|
+
export async function introspect(options?: {
|
|
364
|
+
/**
|
|
365
|
+
* Take the per-table `COUNT(*)`. The schema listing displays it; nothing
|
|
366
|
+
* else does, and every write introspects to resolve row identity, so the
|
|
367
|
+
* default skips a full scan of every table per write.
|
|
368
|
+
*/
|
|
369
|
+
rowCounts?: boolean
|
|
370
|
+
}): Promise<Map<string, TableFacts>> {
|
|
371
|
+
const wantsCounts = options?.rowCounts === true
|
|
372
|
+
|
|
373
|
+
// `null` from an adapter that cannot answer cheaply - Postgres and MySQL
|
|
374
|
+
// today - and the behavior is then exactly what it was before this cache
|
|
375
|
+
// existed. Correctness does not depend on the capability being present.
|
|
376
|
+
//
|
|
377
|
+
// Probed rather than called, because this plugin is published against a
|
|
378
|
+
// *range* of `@bakery-framework/orm` versions and an adapter can come from a
|
|
379
|
+
// third-party package that predates the method. A `TypeError` here would
|
|
380
|
+
// turn a missing optimization into a failed request; the tests' own stub
|
|
381
|
+
// adapters are the same shape and proved the point immediately.
|
|
382
|
+
const fingerprint =
|
|
383
|
+
wantsCounts || typeof connection.schemaFingerprint !== 'function'
|
|
384
|
+
? null
|
|
385
|
+
: await connection.schemaFingerprint()
|
|
386
|
+
if (fingerprint && cached?.fingerprint === fingerprint) return cached.facts
|
|
387
|
+
|
|
321
388
|
const [schema, constraints, indexes] = await Promise.all([
|
|
322
|
-
connection.getSchema(),
|
|
389
|
+
connection.getSchema({ rowCounts: wantsCounts }),
|
|
323
390
|
connection.getConstraints(),
|
|
324
391
|
connection.getIndexes(),
|
|
325
392
|
])
|
|
@@ -336,6 +403,7 @@ export async function introspect(): Promise<Map<string, TableFacts>> {
|
|
|
336
403
|
name,
|
|
337
404
|
type: index.type,
|
|
338
405
|
cols: index.cols,
|
|
406
|
+
rawCols: index.rawCols,
|
|
339
407
|
}))
|
|
340
408
|
|
|
341
409
|
const identity = describeIdentity({
|
|
@@ -363,15 +431,13 @@ export async function introspect(): Promise<Map<string, TableFacts>> {
|
|
|
363
431
|
columns.find(c => c.meta.kind === 'string' && !identityCols.has(c.name))
|
|
364
432
|
?.name ?? null
|
|
365
433
|
|
|
366
|
-
//
|
|
367
|
-
//
|
|
368
|
-
//
|
|
369
|
-
// dropped, so a mismatch is visible instead of silently missing a column.
|
|
370
|
-
const rawColumns = rawByCamel(table.columns.map(c => c.name))
|
|
434
|
+
// The adapter's raw spelling where it exists, the camel one where it does
|
|
435
|
+
// not: shown as it came rather than dropped, so a mismatch is visible
|
|
436
|
+
// instead of silently missing a column.
|
|
371
437
|
const tableIndexFacts = tableIndexes.map(index => ({
|
|
372
438
|
name: index.name,
|
|
373
439
|
type: index.type,
|
|
374
|
-
cols: index.cols.map(col =>
|
|
440
|
+
cols: index.cols.map((col, i) => index.rawCols?.[i] ?? col),
|
|
375
441
|
}))
|
|
376
442
|
|
|
377
443
|
byTable.set(table.name, {
|
|
@@ -387,5 +453,9 @@ export async function introspect(): Promise<Map<string, TableFacts>> {
|
|
|
387
453
|
})
|
|
388
454
|
}
|
|
389
455
|
|
|
456
|
+
// Stored after the walk rather than before it, so a walk that throws leaves
|
|
457
|
+
// no entry claiming to describe a schema nobody successfully read.
|
|
458
|
+
if (fingerprint) cached = { fingerprint, facts: byTable }
|
|
459
|
+
|
|
390
460
|
return byTable
|
|
391
461
|
}
|
package/src/index.ts
CHANGED
|
@@ -42,7 +42,7 @@ export interface DbExplorerPluginOptions extends AccessConfig {
|
|
|
42
42
|
* })
|
|
43
43
|
* ```
|
|
44
44
|
*
|
|
45
|
-
* Either door admits and the higher level wins
|
|
45
|
+
* Either door admits and the higher level wins, a session admin presenting a
|
|
46
46
|
* read-only key is still an admin. With neither configured the explorer admits
|
|
47
47
|
* nobody, which is the same default it had when it was read-only, and the
|
|
48
48
|
* reason there is no `writes: true` flag to leave set by accident.
|
package/src/policy.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* How much one request may do.
|
|
3
3
|
*
|
|
4
|
-
* These are not rate limits and not tuning knobs
|
|
4
|
+
* These are not rate limits and not tuning knobs: they are the size at which a
|
|
5
5
|
* request stops being an edit and starts being a migration. The explorer has no
|
|
6
6
|
* raw-SQL endpoint precisely so that nothing it exposes can rewrite a table in
|
|
7
7
|
* one call; a `rows` array with no ceiling would put that back, one row at a
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* way to know which 1,000, and the retry duplicates them.
|
|
13
13
|
*
|
|
14
14
|
* The numbers are deliberately round rather than derived. The one real
|
|
15
|
-
* constraint
|
|
15
|
+
* constraint (the ~32,766 bound parameters a statement may carry) is already
|
|
16
16
|
* handled below this layer, by `DB.Insert`'s batching.
|
|
17
17
|
*/
|
|
18
18
|
|
|
@@ -23,7 +23,7 @@ export const LIMITS = {
|
|
|
23
23
|
bulkEdits: 1000,
|
|
24
24
|
/** Keys in one `DELETE /api/_db/rows`. */
|
|
25
25
|
deleteKeys: 1000,
|
|
26
|
-
/** Rows in one `POST /api/_db/import
|
|
26
|
+
/** Rows in one `POST /api/_db/import`: a spreadsheet, not an edit. */
|
|
27
27
|
csvRows: 50_000,
|
|
28
28
|
/** Foreign-key targets resolved in one `POST /api/_db/lookup`. */
|
|
29
29
|
lookupRefs: 200,
|
|
@@ -35,7 +35,7 @@ export type LimitName = keyof typeof LIMITS
|
|
|
35
35
|
* The 413 message for a request that is too big, or `null` if it fits.
|
|
36
36
|
*
|
|
37
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`)
|
|
38
|
+
* indeterminate count as well (a `keys` that is not an array reads as `NaN`):
|
|
39
39
|
* convention 2's shape, applied to a bound rather than to an identity.
|
|
40
40
|
*/
|
|
41
41
|
export function overLimit(limit: LimitName, count: number): string | null {
|
package/src/preview.ts
CHANGED
|
@@ -3,14 +3,14 @@
|
|
|
3
3
|
*
|
|
4
4
|
* **This is a workaround for a gap in the ORM, and it should be read as one.**
|
|
5
5
|
* `SQLAdapter.transaction()` commits when the callback returns and rolls back
|
|
6
|
-
* when it throws
|
|
6
|
+
* when it throws: there is no third outcome, so there is no way to say "undo
|
|
7
7
|
* this, and here is what it would have done". A dry run needs exactly that: the
|
|
8
8
|
* statements have to actually execute (otherwise the report is a guess about
|
|
9
9
|
* constraints the database has not been asked about), and then not stick.
|
|
10
10
|
*
|
|
11
11
|
* So the report rides out on the exception. The transaction rolls back because
|
|
12
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
|
|
13
|
+
* an ordinary response, and anything that is **not** this class is rethrown,
|
|
14
14
|
* which is the part that has to stay right. A `catch` that swallowed a real
|
|
15
15
|
* error here would report a failed write as a successful preview.
|
|
16
16
|
*
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
*
|
|
25
25
|
* `status` because two different outcomes need the same mechanism: a dry run
|
|
26
26
|
* answers 200 with what *would* have happened, and a bulk edit that hit an
|
|
27
|
-
*
|
|
27
|
+
* optimiztic-concurrency conflict answers 409 with what did, and both have to
|
|
28
28
|
* leave the database untouched, so both have to leave through a throw.
|
|
29
29
|
*/
|
|
30
30
|
export class RollbackSignal<T = unknown> extends Error {
|
|
@@ -40,12 +40,12 @@ export class RollbackSignal<T = unknown> extends Error {
|
|
|
40
40
|
|
|
41
41
|
/** A dry run: nothing kept, 200 with the report. */
|
|
42
42
|
export function previewRollback<T>(report: T): never {
|
|
43
|
-
throw new RollbackSignal(report, 200, 'dry run
|
|
43
|
+
throw new RollbackSignal(report, 200, 'dry run: rolled back')
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
/** A conflict: nothing kept, 409 with the report. */
|
|
47
47
|
export function conflictRollback<T>(report: T): never {
|
|
48
|
-
throw new RollbackSignal(report, 409, 'conflict
|
|
48
|
+
throw new RollbackSignal(report, 409, 'conflict, rolled back')
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
export function isRollbackSignal(error: unknown): error is RollbackSignal {
|
package/src/setup.ts
CHANGED
|
@@ -6,7 +6,7 @@ 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
8
|
import { response } from '@bakery-framework/core/utils/http'
|
|
9
|
-
import {
|
|
9
|
+
import { accessStore, assertValidUsers, resolveAccess, type AccessConfig } from './access'
|
|
10
10
|
import { handleGraph, handleLookup } from './endpoints/graph'
|
|
11
11
|
import { handleImport } from './endpoints/import'
|
|
12
12
|
import { handleSchema, handleTableData } from './endpoints/read'
|
|
@@ -19,7 +19,7 @@ import {
|
|
|
19
19
|
import { SHELL } from './shell'
|
|
20
20
|
|
|
21
21
|
/**
|
|
22
|
-
* Where this plugin's own files live
|
|
22
|
+
* Where this plugin's own files live: each package that ships files anchors
|
|
23
23
|
* to its own location, never to core's (see the dashboard's `paths.ts` for
|
|
24
24
|
* the 404s that lesson cost).
|
|
25
25
|
*/
|
|
@@ -63,23 +63,23 @@ async function handleClientJs() {
|
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
/**
|
|
66
|
-
* The whole request surface, and **the key spellings are the CSRF policy
|
|
66
|
+
* The whole request surface, and **the key spellings are the CSRF policy**,
|
|
67
67
|
* `guardFor` in `plugins/routes.ts` reads them, so getting one wrong here
|
|
68
68
|
* silently loosens a guard rather than failing anywhere visible.
|
|
69
69
|
*
|
|
70
70
|
* - **Bare keys are the reads.** A bare key matches every method and gets
|
|
71
71
|
* `checkSameOrigin` on *all* of them, which is the stricter of the two: no
|
|
72
72
|
* cross-site page reaches these, whatever verb it uses.
|
|
73
|
-
* - **Every write key names its method.** That pins the verb
|
|
74
|
-
* /api/_db/rows` no longer resolves at all
|
|
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
75
|
*
|
|
76
76
|
* This comment used to say the table needed no CSRF middleware because nothing
|
|
77
77
|
* here could mutate anything. That was true when the plugin was read-only and
|
|
78
78
|
* is now false: rows are inserted, edited and deleted below. What is still
|
|
79
79
|
* true, and is the claim that survives, is **structural**: there is no
|
|
80
80
|
* raw-SQL endpoint and nothing that creates, drops or alters a table. The write
|
|
81
|
-
* surface is bounded and enumerable
|
|
82
|
-
* keys below
|
|
81
|
+
* surface is bounded and enumerable (it is exactly the five method-qualified
|
|
82
|
+
* keys below), rather
|
|
83
83
|
* than gated behind a flag the way the dashboard's is.
|
|
84
84
|
*
|
|
85
85
|
* `/api/_db/graph` and `/api/_db/lookup` are reads despite one of them taking a
|
|
@@ -104,6 +104,8 @@ export const explorerRoutes = {
|
|
|
104
104
|
const dispatchExplorerRoute = routeTable(explorerRoutes)
|
|
105
105
|
|
|
106
106
|
export class DbExplorerHandler extends Handler {
|
|
107
|
+
static override namespace = '/_db'
|
|
108
|
+
|
|
107
109
|
static canHandle(path: string) {
|
|
108
110
|
return (
|
|
109
111
|
path === '/_db' ||
|
|
@@ -115,7 +117,7 @@ export class DbExplorerHandler extends Handler {
|
|
|
115
117
|
|
|
116
118
|
static async handle(path: string, req: Request) {
|
|
117
119
|
// Styling and script are not secrets, and letting them through keeps an
|
|
118
|
-
//
|
|
120
|
+
// unauthorized response from rendering unstyled: same split as the
|
|
119
121
|
// dashboard. Everything else fails closed.
|
|
120
122
|
const access = await resolveAccess(req, config)
|
|
121
123
|
if (!/\.(css|js)$/.test(path) && !access) {
|
|
@@ -124,11 +126,11 @@ export class DbExplorerHandler extends Handler {
|
|
|
124
126
|
: response.error('Not Found', 404)
|
|
125
127
|
}
|
|
126
128
|
|
|
127
|
-
// The level travels with the request rather than in a module variable
|
|
129
|
+
// The level travels with the request rather than in a module variable.
|
|
128
130
|
// see `accessStore`. `read` is the floor for the asset paths admitted
|
|
129
131
|
// above, which never consult it but must not run outside a store.
|
|
130
132
|
return await accessStore.run(access || 'read', async () => {
|
|
131
|
-
// Dispatch keys on `url.pathname` from the request itself
|
|
133
|
+
// Dispatch keys on `url.pathname` from the request itself: the `path`
|
|
132
134
|
// argument only steers the auth split above.
|
|
133
135
|
const result = await dispatchExplorerRoute(req)
|
|
134
136
|
return result ?? response.error('Not Found', 404)
|
|
@@ -137,6 +139,10 @@ export class DbExplorerHandler extends Handler {
|
|
|
137
139
|
}
|
|
138
140
|
|
|
139
141
|
export function setupExplorer(options: AccessConfig = {}) {
|
|
142
|
+
// Before anything is registered: a bad level cannot become good later, and a
|
|
143
|
+
// server that boots while refusing the users it was configured with is worse
|
|
144
|
+
// than one that refuses to boot.
|
|
145
|
+
assertValidUsers(options.users)
|
|
140
146
|
config = options
|
|
141
147
|
// Above the content handlers, below nothing that matters: the /_db and
|
|
142
148
|
// /api/_db namespaces are reserved for framework routes (convention 10),
|
package/src/shared/coerce.ts
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Wire value → column value, and the validation that goes with it.
|
|
3
3
|
*
|
|
4
|
-
* **Pure.** No `Bun.*`, no node builtins, no DOM
|
|
4
|
+
* **Pure.** No `Bun.*`, no node builtins, no DOM: convention 5's rule applied
|
|
5
5
|
* inside the plugin, because this module is imported by the endpoints *and*
|
|
6
6
|
* compiled into the browser bundle, so the grid can tell a user their input is
|
|
7
7
|
* wrong before a request is made and get the same answer the server would give.
|
|
8
8
|
* One implementation is the only way those two answers stay equal.
|
|
9
9
|
*
|
|
10
10
|
* **Pure also means importing nothing from `@bakery-framework/*`, including
|
|
11
|
-
* `utils/isomorphic`.** That reads like the one safe exception
|
|
12
|
-
* framework's own pure layer
|
|
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
13
|
* `bundleModule` marks every *installed package* external
|
|
14
14
|
* (`compiler.ts:316`), so a framework import survives into the emitted bundle
|
|
15
15
|
* as a bare specifier, the browser tries to fetch
|
|
@@ -27,9 +27,9 @@
|
|
|
27
27
|
* express "leave this alone", an empty text field cleared to `NULL`, and `""`
|
|
28
28
|
* into a numeric column silently became `0`. Here:
|
|
29
29
|
*
|
|
30
|
-
* - **key absent
|
|
31
|
-
* - **`null
|
|
32
|
-
* - **`""
|
|
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
33
|
* other kind it is an error, never zero and never NULL.
|
|
34
34
|
*
|
|
35
35
|
* And `"007"` is a string in a text column and the number 7 in an integer one,
|
|
@@ -41,8 +41,8 @@
|
|
|
41
41
|
* What kind of value a column holds.
|
|
42
42
|
*
|
|
43
43
|
* The ORM's own `ColumnType` plus `date`. That extra member is not reachable
|
|
44
|
-
* from `getConstraints()`
|
|
45
|
-
* reports `integer`
|
|
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
46
|
* `DATE`/`TIMESTAMP`, which the raw SQL type from `getSchema()` reveals. A
|
|
47
47
|
* genuine date column binds a `Date`, and an integer timestamp binds a number;
|
|
48
48
|
* conflating them is how a timestamp ends up written as the year 1970.
|
|
@@ -101,9 +101,8 @@ const ok = (value: unknown): CoerceResult => ({ ok: true, value })
|
|
|
101
101
|
*
|
|
102
102
|
* `json` and `buffer` cannot, portably: MySQL compares JSON structurally,
|
|
103
103
|
* Postgres refuses `=` on `json` outright (only `jsonb` has it), and a blob
|
|
104
|
-
* comparison depends on how the driver bound the parameter. An
|
|
105
|
-
* `expect` on one of these would be a predicate that silently never matched
|
|
106
|
-
* every edit a 409 — so it is refused instead.
|
|
104
|
+
* comparison depends on how the driver bound the parameter. An optimiztic
|
|
105
|
+
* `expect` on one of these would be a predicate that silently never matched ( * every edit a 409), so it is refused instead.
|
|
107
106
|
*/
|
|
108
107
|
export function comparableKind(kind: ColumnKind): boolean {
|
|
109
108
|
return kind !== 'json' && kind !== 'buffer'
|
|
@@ -133,7 +132,7 @@ function coerceInteger(raw: unknown, meta: ColumnMeta): CoerceResult {
|
|
|
133
132
|
// Narrowed back to a number when it fits, so the common case binds the
|
|
134
133
|
// type every driver has always taken. A value outside the safe range stays
|
|
135
134
|
// a bigint, because turning it into a `number` is the precision loss this
|
|
136
|
-
// branch exists to avoid
|
|
135
|
+
// branch exists to avoid: `bigint` is the ORM's own column kind for it.
|
|
137
136
|
const inSafeRange =
|
|
138
137
|
n <= BigInt(Number.MAX_SAFE_INTEGER) &&
|
|
139
138
|
n >= BigInt(Number.MIN_SAFE_INTEGER)
|
|
@@ -187,7 +186,7 @@ function coerceBoolean(raw: unknown): CoerceResult {
|
|
|
187
186
|
/**
|
|
188
187
|
* A real date/time column, bound as a `Date`.
|
|
189
188
|
*
|
|
190
|
-
* Accepts an ISO-8601 string or a number of **milliseconds** since the epoch
|
|
189
|
+
* Accepts an ISO-8601 string or a number of **milliseconds** since the epoch:
|
|
191
190
|
* milliseconds because that is what `Date.now()` and `JSON.stringify(new Date)`
|
|
192
191
|
* produce on the client, and a value that is ambiguous between the two units
|
|
193
192
|
* does not exist: an integer timestamp column reports kind `integer` and never
|
|
@@ -215,9 +214,9 @@ function coerceDate(raw: unknown): CoerceResult {
|
|
|
215
214
|
/**
|
|
216
215
|
* A JSON column, stored as text.
|
|
217
216
|
*
|
|
218
|
-
* The ORM
|
|
219
|
-
* application decides what goes in it
|
|
220
|
-
* text that is not JSON. An object or array on the wire is
|
|
217
|
+
* The ORM serializes nothing: `Field.Json` declares the column type and the
|
|
218
|
+
* application decides what goes in it, so the explorer stores text and refuses
|
|
219
|
+
* text that is not JSON. An object or array on the wire is serialized here
|
|
221
220
|
* rather than at the call site, so the grid can send either spelling.
|
|
222
221
|
*/
|
|
223
222
|
function coerceJson(raw: unknown): CoerceResult {
|
|
@@ -237,7 +236,7 @@ function coerceJson(raw: unknown): CoerceResult {
|
|
|
237
236
|
return ok(JSON.stringify(raw))
|
|
238
237
|
} catch {
|
|
239
238
|
// A cycle, or a BigInt. Same reasoning as above.
|
|
240
|
-
return fail('bad_json', 'value cannot be
|
|
239
|
+
return fail('bad_json', 'value cannot be serialized as JSON')
|
|
241
240
|
}
|
|
242
241
|
}
|
|
243
242
|
return fail('type', `expected JSON, got ${typeName(raw)}`)
|
|
@@ -312,7 +311,7 @@ export function coerceValue(raw: unknown, meta: ColumnMeta): CoerceResult {
|
|
|
312
311
|
if (raw === '' && meta.kind !== 'string') {
|
|
313
312
|
// The whole reason this function returns a result rather than a value.
|
|
314
313
|
// `Number('')` is 0, `Boolean('')` is false and `new Date('')` is Invalid
|
|
315
|
-
// Date
|
|
314
|
+
// Date: three different wrong answers for the same input, which is what
|
|
316
315
|
// the dashboard's editor shipped.
|
|
317
316
|
return fail(
|
|
318
317
|
'empty_string',
|
|
@@ -351,16 +350,16 @@ export function omittableOnInsert(meta: ColumnMeta): boolean {
|
|
|
351
350
|
}
|
|
352
351
|
|
|
353
352
|
/**
|
|
354
|
-
* "Did the user actually change this?"
|
|
353
|
+
* "Did the user actually change this?", deliberately loose.
|
|
355
354
|
*
|
|
356
355
|
* The two operands come from different worlds: the left is a value the database
|
|
357
356
|
* returned, the right is whatever JSON the browser sent, and a driver that
|
|
358
357
|
* hands back `1` for a boolean or a string for a `BIGINT` is not a difference
|
|
359
358
|
* the user made. Strict equality here would send a `set` full of columns nobody
|
|
360
|
-
* touched, which is how an
|
|
359
|
+
* touched, which is how an optimiztic-concurrency check turns into a conflict
|
|
361
360
|
* for every concurrent editor of any column.
|
|
362
361
|
*
|
|
363
|
-
* This is used to *shrink* a statement, never to decide correctness
|
|
362
|
+
* This is used to *shrink* a statement, never to decide correctness: the
|
|
364
363
|
* server's own conflict check is a SQL predicate, not this.
|
|
365
364
|
*/
|
|
366
365
|
export function sameValue(a: unknown, b: unknown): boolean {
|
|
@@ -379,7 +378,7 @@ export function sameValue(a: unknown, b: unknown): boolean {
|
|
|
379
378
|
}
|
|
380
379
|
if (typeof a === 'object' || typeof b === 'object') {
|
|
381
380
|
// A cyclic object throws in `JSON.stringify`, and "could not be compared"
|
|
382
|
-
// has to mean "not equal"
|
|
381
|
+
// has to mean "not equal": the conservative direction, because it keeps
|
|
383
382
|
// the column in the statement rather than dropping an edit the user made.
|
|
384
383
|
//
|
|
385
384
|
// A bare try/catch rather than core's `Try`, and that is a constraint of
|