@bakery-framework/plugin-db-explorer 2.0.0-alpha.8 → 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.
Files changed (49) hide show
  1. package/package.json +5 -5
  2. package/src/access.ts +53 -17
  3. package/src/client/api.ts +48 -6
  4. package/src/client/bulk.ts +31 -7
  5. package/src/client/cell.ts +5 -5
  6. package/src/client/confirm.ts +11 -12
  7. package/src/client/csv-commit.ts +25 -5
  8. package/src/client/csv-map.ts +7 -7
  9. package/src/client/csv-model.ts +61 -8
  10. package/src/client/csv-pick.ts +1 -1
  11. package/src/client/csv-preview.ts +2 -2
  12. package/src/client/csv.ts +2 -2
  13. package/src/client/dom.ts +3 -5
  14. package/src/client/edit-session.ts +7 -7
  15. package/src/client/editors.ts +5 -5
  16. package/src/client/filter-builder.ts +6 -6
  17. package/src/client/fk.ts +9 -9
  18. package/src/client/grid-body.ts +4 -4
  19. package/src/client/grid-header.ts +2 -2
  20. package/src/client/grid-rowbar.ts +1 -1
  21. package/src/client/grid.ts +8 -9
  22. package/src/client/meta.ts +6 -6
  23. package/src/client/page.ts +4 -4
  24. package/src/client/panel.ts +8 -8
  25. package/src/client/relations.ts +4 -4
  26. package/src/client/save.ts +4 -4
  27. package/src/client/sidebar.ts +6 -6
  28. package/src/client/state.ts +4 -4
  29. package/src/client/statusbar.ts +6 -6
  30. package/src/client/structure.ts +8 -8
  31. package/src/client/tabs.ts +10 -10
  32. package/src/client/tabstrip.ts +3 -3
  33. package/src/client.ts +17 -17
  34. package/src/endpoints/common.ts +25 -6
  35. package/src/endpoints/graph.ts +5 -5
  36. package/src/endpoints/import.ts +5 -5
  37. package/src/endpoints/read.ts +70 -10
  38. package/src/endpoints/rows.ts +219 -14
  39. package/src/identity.ts +122 -52
  40. package/src/index.ts +1 -1
  41. package/src/policy.ts +4 -4
  42. package/src/preview.ts +5 -5
  43. package/src/setup.ts +16 -10
  44. package/src/shared/coerce.ts +21 -22
  45. package/src/shared/csv.ts +5 -5
  46. package/src/shared/filters.ts +6 -6
  47. package/src/shared/plan.ts +7 -7
  48. package/src/shell.ts +3 -3
  49. 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 — a 409, not a
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
- * 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.
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
- /** This table's `getIndexes()` entries, with camel-cased column names. */
84
- indexes: { name: string; type: string; cols: string[] }[]
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` — the single SQL identifier writer (convention 8) — snake-cases before
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 — walked in
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
- cols: index.cols.map(camel => raw.get(camel)),
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(Case.camel(col))?.nullable === false,
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 — what goes into a statement. */
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
- rowCount: number
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 — `describeIdentity` walks them to find a usable
239
- * unique key — and now carried out rather than discarded, because the
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 — what a foreign-key
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 — and the insert dialog would happily leave
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, 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.
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(): Promise<Map<string, TableFacts>> {
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
- // `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))
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 => rawColumns.get(col) ?? 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 — a session admin presenting a
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 — they are the size at which a
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 — the ~32,766 bound parameters a statement may carry — is already
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` — a spreadsheet, not an edit. */
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 — there is no third outcome, so there is no way to say "undo
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
- * optimistic-concurrency conflict answers 409 with what did — and both have to
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 — rolled back')
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 — rolled back')
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 { type AccessConfig, accessStore, resolveAccess } from './access'
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 — each package that ships files anchors
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 — a `GET
74
- * /api/_db/rows` no longer resolves at all — and applies `checkCsrf`.
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 — it is exactly the five method-qualified
82
- * keys below — rather
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
- // unauthorised response from rendering unstyled — same split as the
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 — the `path`
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),
@@ -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 — convention 5's rule applied
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 — it is the
12
- * framework's own pure layer — and it is not, for a mechanical reason:
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** — 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
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()` — the ORM stores timestamps as integer seconds, so it
45
- * reports `integer` — and exists for a column some other tool created as a real
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 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.
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 — `bigint` is the ORM's own column kind for it.
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 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
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 serialised as JSON')
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 — three different wrong answers for the same input, which is what
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?" — deliberately loose.
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 optimistic-concurrency check turns into a conflict
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 — the
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" — the conservative direction, because it keeps
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