@bakery-framework/plugin-db-explorer 2.0.0-alpha.11

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 +42 -0
  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 +409 -0
  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 +175 -0
  38. package/src/endpoints/rows.ts +435 -0
  39. package/src/identity.ts +401 -0
  40. package/src/index.ts +68 -0
  41. package/src/policy.ts +45 -0
  42. package/src/preview.ts +53 -0
  43. package/src/setup.ts +147 -0
  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
@@ -0,0 +1,401 @@
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
+ * `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.
35
+ *
36
+ * And `getConstraints()` includes **views**, which carry a `_view` key. A view
37
+ * has no rows of its own to address, so it is always `none`.
38
+ */
39
+
40
+ import { Case } from '@bakery-framework/core/utils'
41
+ import type { SQLAdapter } from '@bakery-framework/orm/adapters'
42
+ import { connection } from '@bakery-framework/orm/connection'
43
+ import type { ColumnKind, ColumnMeta } from './shared/coerce'
44
+
45
+ /**
46
+ * The shapes the adapter reports, derived from its own signatures rather than
47
+ * imported: `@bakery-framework/orm` does not export `sync/types`, and adding a
48
+ * subpath to another package's export map to read three optional fields is a
49
+ * public-API decision this plugin does not get to make on its own.
50
+ */
51
+ export type TableConstraints = Awaited<
52
+ ReturnType<SQLAdapter['getConstraints']>
53
+ >[string]
54
+ export type TableDetails = Awaited<ReturnType<SQLAdapter['getSchema']>>[number]
55
+ export type IndexEntry = Awaited<ReturnType<SQLAdapter['getIndexes']>>[string]
56
+
57
+ /**
58
+ * One column of `TableConstraints`, structurally.
59
+ *
60
+ * Spelled out because `TableConstraints` is an intersection of an index
61
+ * signature with `_view`/`_oldTable`/`_transform`, so indexing it does not
62
+ * narrow to the column type on its own.
63
+ */
64
+ export interface ColumnConstraint {
65
+ type?: string
66
+ length?: number
67
+ _enum?: readonly string[]
68
+ primary?: boolean
69
+ autoIncrement?: boolean
70
+ nullable?: boolean
71
+ default?: unknown
72
+ }
73
+
74
+ export type IdentityMode = 'pk' | 'unique' | 'none'
75
+
76
+ export interface Identity {
77
+ mode: IdentityMode
78
+ /** Raw database column names, in a stable order. Empty when `mode` is `none`. */
79
+ cols: string[]
80
+ /** Why there is no identity. Present only when `mode` is `none`. */
81
+ reason?: string
82
+ }
83
+
84
+ export interface TableIntrospection {
85
+ /** The raw database table name. */
86
+ name: string
87
+ /** `getConstraints()[Case.camel(name)]`, camel-keyed, possibly `_view`. */
88
+ constraints: TableConstraints
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[] }[]
94
+ /** Raw database column names, in `getSchema()` order. */
95
+ columns: string[]
96
+ }
97
+
98
+ /**
99
+ * Can this identifier survive being written into a statement?
100
+ *
101
+ * `qId` — the single SQL identifier writer (convention 8) — snake-cases before
102
+ * quoting, so `qId('Orders')` emits `"orders"`. For every name the ORM created
103
+ * that is a no-op, because it snake-cases on the way in too. For a table some
104
+ * other tool created with a capital in it, it is a statement against a
105
+ * different object, and on a case-sensitive MySQL install that object may not
106
+ * exist or may be a different table entirely.
107
+ *
108
+ * So a name that does not round-trip is refused rather than quietly rewritten.
109
+ * The cost is that such a table is read-only in the explorer; the alternative
110
+ * is writing to a table the user did not name.
111
+ */
112
+ export function isAddressable(name: string): boolean {
113
+ return Case.snake(name) === name
114
+ }
115
+
116
+ /**
117
+ * A column key of `TableConstraints` that is really a column.
118
+ *
119
+ * `_view`, `_oldTable` and `_transform` share the same object. No real column
120
+ * can collide with them: `Case.camel` strips a leading underscore, so a column
121
+ * genuinely named `_view` is filed under `view`.
122
+ */
123
+ function isColumnKey(key: string): boolean {
124
+ return !key.startsWith('_')
125
+ }
126
+
127
+ const none = (reason: string): Identity => ({ mode: 'none', cols: [], reason })
128
+
129
+ /**
130
+ * The identity of one table, in the order the brief fixes: declared primary
131
+ * key, else the narrowest all-NOT-NULL unique index, else nothing.
132
+ */
133
+ export function describeIdentity(table: TableIntrospection): Identity {
134
+ if (!isAddressable(table.name)) {
135
+ return none(
136
+ `the table name ${table.name} is not addressable — ` +
137
+ 'identifiers are snake-cased before they are quoted',
138
+ )
139
+ }
140
+
141
+ const constraints = table.constraints as Record<string, unknown>
142
+ if (typeof constraints._view === 'string') {
143
+ return none('a view has no rows of its own to address')
144
+ }
145
+
146
+ const column = (camel: string): ColumnConstraint | undefined =>
147
+ isColumnKey(camel) ? (constraints[camel] as ColumnConstraint) : undefined
148
+
149
+ // 1. Primary key. Composite is the normal case, not an exception — walked in
150
+ // `getSchema()` column order so a composite key has a stable spelling
151
+ // rather than whatever order the introspection query returned.
152
+ const primary = table.columns.filter(
153
+ name => column(Case.camel(name))?.primary === true,
154
+ )
155
+ if (primary.length) {
156
+ const unaddressable = primary.filter(c => !isAddressable(c))
157
+ if (unaddressable.length) {
158
+ return none(
159
+ `primary key columns are not addressable: ${unaddressable.join(', ')}`,
160
+ )
161
+ }
162
+ return { mode: 'pk', cols: primary }
163
+ }
164
+
165
+ // 2. The narrowest unique index whose every column is declared NOT NULL.
166
+ // NULL is the whole reason for that condition: `NULL = NULL` is unknown,
167
+ // so a predicate over a nullable unique column matches no row, and an
168
+ // UPDATE that reports zero changes is indistinguishable from a conflict.
169
+ const candidates = table.indexes
170
+ .filter(index => index.type === 'unique')
171
+ .map(index => ({
172
+ name: index.name,
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
+ })),
182
+ }))
183
+ .filter(
184
+ (index): index is { name: string; cols: { camel: string; raw: string }[] } =>
185
+ index.cols.length > 0 &&
186
+ index.cols.every(
187
+ col =>
188
+ typeof col.raw === 'string' &&
189
+ isAddressable(col.raw) &&
190
+ // `nullable === false` explicitly, never `!nullable`. An adapter
191
+ // that reported nothing for a column would otherwise read as NOT
192
+ // NULL, which is the fail-open direction (convention 2).
193
+ column(col.camel)?.nullable === false,
194
+ ),
195
+ )
196
+ // Narrowest first; ties broken by index name so two equally narrow keys
197
+ // do not depend on introspection order.
198
+ .sort(
199
+ (a, b) => a.cols.length - b.cols.length || a.name.localeCompare(b.name),
200
+ )
201
+
202
+ const chosen = candidates[0]
203
+ if (chosen) return { mode: 'unique', cols: chosen.cols.map(c => c.raw) }
204
+
205
+ return none(
206
+ 'no primary key and no unique index over NOT NULL columns — ' +
207
+ 'there is no way to name one row of this table',
208
+ )
209
+ }
210
+
211
+ export interface ColumnFacts {
212
+ /** The raw database column name — what goes into a statement. */
213
+ name: string
214
+ /** The key `getConstraints()` filed it under. */
215
+ camel: string
216
+ /** The database's own type string, from `getSchema()`. */
217
+ sqlType: string
218
+ meta: ColumnMeta
219
+ }
220
+
221
+ /** One index, with its columns translated back to raw database names. */
222
+ export interface IndexFacts {
223
+ name: string
224
+ type: string
225
+ /** Raw column names where they resolve, the camel spelling where they do not. */
226
+ cols: string[]
227
+ }
228
+
229
+ export interface TableFacts {
230
+ name: string
231
+ camel: string
232
+ isView: boolean
233
+ /** `null` unless `introspect({ rowCounts: true })` was asked — see below. */
234
+ rowCount: number | null
235
+ columns: ColumnFacts[]
236
+ /** By raw column name. */
237
+ byName: Map<string, ColumnFacts>
238
+ identity: Identity
239
+ /**
240
+ * Declared indexes.
241
+ *
242
+ * Computed here already — `describeIdentity` walks them to find a usable
243
+ * unique key — and now carried out rather than discarded, because the
244
+ * Structure view shows them and there is no second endpoint that knows them.
245
+ */
246
+ indexes: IndexFacts[]
247
+ /**
248
+ * The first text column that is not part of the identity — what a foreign-key
249
+ * reference shows instead of a bare id. `null` when the table has none.
250
+ */
251
+ label: string | null
252
+ }
253
+
254
+ /** Genuine date/time column types, as the three dialects spell them. */
255
+ const RX_DATE_TYPE = /^(date|datetime|timestamp)/i
256
+
257
+ /**
258
+ * What kind of value a column holds.
259
+ *
260
+ * `getConstraints()` is the authority, with one exception it cannot express:
261
+ * `ColumnType` has no date member, so every adapter maps a real `DATE` or
262
+ * `TIMESTAMP` to `string` (MySQL explicitly, Postgres by falling through). The
263
+ * raw SQL type is the only place that distinction survives, and it matters —
264
+ * `''` into a text column is an empty string and into a timestamp is an error.
265
+ */
266
+ function kindOf(declared: string | undefined, sqlType: string): ColumnKind {
267
+ const known: ColumnKind[] = [
268
+ 'integer',
269
+ 'number',
270
+ 'bigint',
271
+ 'string',
272
+ 'boolean',
273
+ 'json',
274
+ 'buffer',
275
+ ]
276
+ const kind = known.find(k => k === declared) ?? 'string'
277
+ if (kind === 'string' && RX_DATE_TYPE.test(sqlType.trim())) return 'date'
278
+ return kind
279
+ }
280
+
281
+ export function metaOf(
282
+ constraint: ColumnConstraint | undefined,
283
+ schemaColumn: TableDetails['columns'][number],
284
+ ): ColumnMeta {
285
+ return {
286
+ kind: kindOf(constraint?.type, schemaColumn.type ?? ''),
287
+ // `getConstraints()` when it says anything, `getSchema()`'s NOT NULL flag
288
+ // otherwise. Both are the database's own answer; the first is the richer
289
+ // one and the second is never absent.
290
+ nullable: constraint?.nullable ?? !schemaColumn.notnull,
291
+ length: constraint?.length,
292
+ enum: constraint?._enum,
293
+ // **Not `'default' in constraint`.** That reads as the careful choice —
294
+ // `DEFAULT NULL` is a real default and is filed as `default: null`, so the
295
+ // key's presence looks like the honest test. It is not: `parseConstraints`
296
+ // writes the key on **every** column, defaulted or not, so `in` answered
297
+ // `true` for all of them.
298
+ //
299
+ // That is not cosmetic. `omittableOnInsert` is
300
+ // `autoIncrement || hasDefault || nullable`, so an always-true `hasDefault`
301
+ // makes every column omittable — and the insert dialog would happily leave
302
+ // out a NOT NULL column with no default, to be refused by the database
303
+ // rather than by the form that knew.
304
+ //
305
+ // A column genuinely declared `DEFAULT NULL` reads as having none here. It
306
+ // loses nothing: such a column is nullable, and `nullable` is the next term
307
+ // in that same expression.
308
+ hasDefault: constraint?.default !== null && constraint?.default !== undefined,
309
+ primary: constraint?.primary ?? schemaColumn.pk,
310
+ autoIncrement: constraint?.autoIncrement,
311
+ }
312
+ }
313
+
314
+ /**
315
+ * Every table the connection has, with its identity resolved.
316
+ *
317
+ * Three round trips, taken per request rather than cached. Convention 6 forbids
318
+ * an unbounded module cache, and a bounded one would be worse than none here:
319
+ * the wrong entry is not a slow response, it is a write addressed by a key the
320
+ * table no longer has. Schema introspection is also exactly what the explorer's
321
+ * own `/api/_db/schema` call already costs, so a write pays what a page load
322
+ * pays.
323
+ */
324
+ export async function introspect(options?: {
325
+ /**
326
+ * Take the per-table `COUNT(*)`. The schema listing displays it; nothing
327
+ * else does, and every write introspects to resolve row identity — so the
328
+ * default skips a full scan of every table per write.
329
+ */
330
+ rowCounts?: boolean
331
+ }): Promise<Map<string, TableFacts>> {
332
+ const [schema, constraints, indexes] = await Promise.all([
333
+ connection.getSchema({ rowCounts: options?.rowCounts === true }),
334
+ connection.getConstraints(),
335
+ connection.getIndexes(),
336
+ ])
337
+
338
+ const byTable = new Map<string, TableFacts>()
339
+ for (const table of schema) {
340
+ const camel = Case.camel(table.name)
341
+ const tableConstraints = (constraints[camel] ?? {}) as TableConstraints
342
+ const raw = tableConstraints as Record<string, unknown>
343
+
344
+ const tableIndexes = Object.entries(indexes)
345
+ .filter(([, index]) => index.table === camel)
346
+ .map(([name, index]) => ({
347
+ name,
348
+ type: index.type,
349
+ cols: index.cols,
350
+ rawCols: index.rawCols,
351
+ }))
352
+
353
+ const identity = describeIdentity({
354
+ name: table.name,
355
+ constraints: tableConstraints,
356
+ indexes: tableIndexes,
357
+ columns: table.columns.map(c => c.name),
358
+ })
359
+
360
+ const columns = table.columns.map(column => {
361
+ const columnCamel = Case.camel(column.name)
362
+ const constraint = isColumnKey(columnCamel)
363
+ ? (raw[columnCamel] as ColumnConstraint | undefined)
364
+ : undefined
365
+ return {
366
+ name: column.name,
367
+ camel: columnCamel,
368
+ sqlType: column.type ?? '',
369
+ meta: metaOf(constraint, column),
370
+ }
371
+ })
372
+
373
+ const identityCols = new Set(identity.cols)
374
+ const label =
375
+ columns.find(c => c.meta.kind === 'string' && !identityCols.has(c.name))
376
+ ?.name ?? null
377
+
378
+ // The adapter's raw spelling where it exists, the camel one where it does
379
+ // not — shown as it came rather than dropped, so a mismatch is visible
380
+ // instead of silently missing a column.
381
+ const tableIndexFacts = tableIndexes.map(index => ({
382
+ name: index.name,
383
+ type: index.type,
384
+ cols: index.cols.map((col, i) => index.rawCols?.[i] ?? col),
385
+ }))
386
+
387
+ byTable.set(table.name, {
388
+ name: table.name,
389
+ camel,
390
+ isView: typeof raw._view === 'string',
391
+ rowCount: table.rowCount,
392
+ columns,
393
+ byName: new Map(columns.map(c => [c.name, c])),
394
+ identity,
395
+ indexes: tableIndexFacts,
396
+ label,
397
+ })
398
+ }
399
+
400
+ return byTable
401
+ }
package/src/index.ts ADDED
@@ -0,0 +1,68 @@
1
+ import { definePlugin } from '@bakery-framework/core/plugins'
2
+ import type { AccessConfig } from './access'
3
+
4
+ /**
5
+ * Part of this plugin's public surface.
6
+ *
7
+ * These are the explorer's own rather than core's `AuthorizeFn`, because the
8
+ * explorer asks a different question. The dashboard and analytics need to know
9
+ * *whether* a request is admitted; the explorer edits rows, so it needs to know
10
+ * *what* the request may do. A boolean cannot carry that.
11
+ */
12
+ export type { Access, AccessFn, ExplorerUser, ExplorerUsers } from './access'
13
+
14
+ export interface DbExplorerPluginOptions extends AccessConfig {
15
+ /**
16
+ * Register the explorer. Defaults to true; set false to keep it out of a
17
+ * build entirely.
18
+ */
19
+ enabled?: boolean
20
+ }
21
+
22
+ /**
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.
49
+ *
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.
54
+ */
55
+ export default function dbExplorerPlugin(
56
+ options: DbExplorerPluginOptions = {},
57
+ ) {
58
+ const enabled = options.enabled ?? true
59
+
60
+ return definePlugin({
61
+ name: 'db-explorer',
62
+ async setup() {
63
+ if (!enabled) return
64
+ const { setupExplorer } = await import('./setup')
65
+ setupExplorer({ users: options.users, authorize: options.authorize })
66
+ },
67
+ })
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
+ }