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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/package.json +4 -4
  2. package/src/access.ts +188 -0
  3. package/src/client/api.ts +279 -0
  4. package/src/client/bulk.ts +357 -0
  5. package/src/client/cell.ts +139 -0
  6. package/src/client/confirm.ts +203 -0
  7. package/src/client/csv-commit.ts +185 -0
  8. package/src/client/csv-map.ts +274 -0
  9. package/src/client/csv-model.ts +420 -0
  10. package/src/client/csv-pick.ts +54 -0
  11. package/src/client/csv-preview.ts +89 -0
  12. package/src/client/csv.ts +104 -0
  13. package/src/client/dom.ts +164 -0
  14. package/src/client/edit-session.ts +219 -0
  15. package/src/client/editors.ts +283 -0
  16. package/src/client/filter-builder.ts +198 -0
  17. package/src/client/fk.ts +269 -0
  18. package/src/client/grid-body.ts +106 -0
  19. package/src/client/grid-header.ts +65 -0
  20. package/src/client/grid-rowbar.ts +64 -0
  21. package/src/client/grid.ts +468 -0
  22. package/src/client/meta.ts +185 -0
  23. package/src/client/page.ts +332 -0
  24. package/src/client/panel.ts +296 -0
  25. package/src/client/relations.ts +205 -0
  26. package/src/client/save.ts +205 -0
  27. package/src/client/sidebar.ts +110 -0
  28. package/src/client/state.ts +218 -0
  29. package/src/client/statusbar.ts +130 -0
  30. package/src/client/structure.ts +234 -0
  31. package/src/client/tabs.ts +219 -0
  32. package/src/client/tabstrip.ts +127 -0
  33. package/src/client.ts +376 -160
  34. package/src/endpoints/common.ts +122 -0
  35. package/src/endpoints/graph.ts +148 -0
  36. package/src/endpoints/import.ts +89 -0
  37. package/src/endpoints/read.ts +173 -0
  38. package/src/endpoints/rows.ts +435 -0
  39. package/src/identity.ts +391 -0
  40. package/src/index.ts +40 -45
  41. package/src/policy.ts +45 -0
  42. package/src/preview.ts +53 -0
  43. package/src/setup.ts +64 -81
  44. package/src/shared/coerce.ts +399 -0
  45. package/src/shared/csv.ts +186 -0
  46. package/src/shared/filters.ts +200 -0
  47. package/src/shared/plan.ts +173 -0
  48. package/src/shell.ts +187 -0
  49. package/src/validate.ts +295 -0
  50. package/src/credential.ts +0 -26
  51. package/src/endpoints.ts +0 -48
@@ -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,58 +1,56 @@
1
1
  import { definePlugin } from '@bakery-framework/core/plugins'
2
- import type { AuthorizeFn } from '@bakery-framework/core/utils/http'
2
+ import type { AccessConfig } from './access'
3
3
 
4
4
  /**
5
- * Part of this plugin's public surface, and re-exported rather than declared:
6
- * the predicate type is core's now, shared with the dashboard and analytics,
7
- * so an application can write one `AuthorizeFn` and hand it to all three.
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.
8
11
  */
9
- export type { AuthorizeFn } from '@bakery-framework/core/utils/http'
12
+ export type { Access, AccessFn, ExplorerUser, ExplorerUsers } from './access'
10
13
 
11
- export interface DbExplorerPluginOptions {
14
+ export interface DbExplorerPluginOptions extends AccessConfig {
12
15
  /**
13
16
  * Register the explorer. Defaults to true; set false to keep it out of a
14
17
  * build entirely.
15
18
  */
16
19
  enabled?: boolean
17
-
18
- /**
19
- * Decide whether a request may browse the database. Return true to allow.
20
- *
21
- * The explorer authenticates nobody itself — the application does, because
22
- * it already knows who its users are:
23
- *
24
- * ```ts
25
- * dbExplorerPlugin({
26
- * authorize: req => req.session.get('role') === 'admin',
27
- * })
28
- * ```
29
- *
30
- * Omitted, access is loopback-only in development and denied in
31
- * production, so an unconfigured explorer is never exposed.
32
- */
33
- authorize?: AuthorizeFn
34
-
35
- /**
36
- * A shared access key, typically from the environment:
37
- *
38
- * ```ts
39
- * dbExplorerPlugin({ credential: import.meta.env.DB_EXPLORER_KEY })
40
- * ```
41
- *
42
- * Presented as `Authorization: Bearer`, an `x-db-key` header, or a
43
- * one-time `?key=` query for a browser (stored client-side, stripped from
44
- * the URL). Checked in constant time. Unset or empty means this path is
45
- * off — it never means open. Composes with `authorize`: either admits.
46
- */
47
- credential?: string
48
20
  }
49
21
 
50
22
  /**
51
- * A read-only database browser at `/_db`: table list, rows, paging, sorting.
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.
52
49
  *
53
- * Read-only is the contract, not a mode. There is no raw-SQL endpoint, no
54
- * row mutation, and no DDL where the dashboard gates its write paths
55
- * behind `DASHBOARD_ALLOW_WRITES`, the explorer has no write paths to gate.
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.
56
54
  */
57
55
  export default function dbExplorerPlugin(
58
56
  options: DbExplorerPluginOptions = {},
@@ -64,10 +62,7 @@ export default function dbExplorerPlugin(
64
62
  async setup() {
65
63
  if (!enabled) return
66
64
  const { setupExplorer } = await import('./setup')
67
- setupExplorer({
68
- authorize: options.authorize,
69
- credential: options.credential,
70
- })
65
+ setupExplorer({ users: options.users, authorize: options.authorize })
71
66
  },
72
67
  })
73
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
+ }