bunderstack 0.17.0-beta.0 → 0.17.0-beta.2

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bunderstack",
3
- "version": "0.17.0-beta.0",
3
+ "version": "0.17.0-beta.2",
4
4
  "description": "Batteries-included backend framework for Bun: type-safe oRPC APIs, auth, storage, realtime, jobs, email, and validated env from one config.",
5
5
  "keywords": [
6
6
  "backend",
@@ -26,6 +26,7 @@
26
26
  "files": [
27
27
  "src",
28
28
  "!src/**/*.test.ts",
29
+ "!src/**/*.types.ts",
29
30
  "README.md",
30
31
  "LICENSE"
31
32
  ],
@@ -66,8 +67,9 @@
66
67
  "devDependencies": {
67
68
  "@electric-sql/pglite": ">=0.3.0",
68
69
  "@libsql/client": ">=0.14.0",
69
- "@orpc/openapi": "2.0.0-beta.26",
70
70
  "@orpc/bun": "2.0.0-beta.26",
71
+ "@orpc/json-schema": "2.0.0-beta.26",
72
+ "@orpc/openapi": "2.0.0-beta.26",
71
73
  "@orpc/publisher": "2.0.0-beta.26",
72
74
  "@orpc/server": "2.0.0-beta.26",
73
75
  "@orpc/valibot": "2.0.0-beta.26",
@@ -80,8 +82,9 @@
80
82
  "peerDependencies": {
81
83
  "@electric-sql/pglite": ">=0.3.0",
82
84
  "@libsql/client": ">=0.14.0",
83
- "@orpc/openapi": "2.0.0-beta.26",
84
85
  "@orpc/bun": "2.0.0-beta.26",
86
+ "@orpc/json-schema": "2.0.0-beta.26",
87
+ "@orpc/openapi": "2.0.0-beta.26",
85
88
  "@orpc/publisher": "2.0.0-beta.26",
86
89
  "@orpc/server": "2.0.0-beta.26",
87
90
  "@orpc/valibot": "2.0.0-beta.26",
package/src/access.ts CHANGED
@@ -38,16 +38,6 @@ export type ScopeResolver = (ctx: AccessContext) => ScopeMap
38
38
 
39
39
  export type CrudOperation = 'list' | 'get' | 'create' | 'update' | 'delete'
40
40
 
41
- const RESERVED_LIST_PARAMS = new Set([
42
- 'limit',
43
- 'offset',
44
- 'sort',
45
- 'order',
46
- 'q',
47
- 'cursor',
48
- 'count',
49
- ])
50
-
51
41
  export type SortOrder = 'asc' | 'desc'
52
42
 
53
43
  export type DefaultSort = {
@@ -176,11 +166,6 @@ function resolveListAccess(
176
166
 
177
167
  const filterableColumns = input.filterableColumns ?? []
178
168
  for (const col of filterableColumns) {
179
- if (RESERVED_LIST_PARAMS.has(col)) {
180
- throw new Error(
181
- `[bunderstack] filterableColumns cannot include reserved query param "${col}"`,
182
- )
183
- }
184
169
  if (!columns.includes(col)) {
185
170
  throw new Error(
186
171
  `[bunderstack] filterableColumns references unknown column "${col}"`,
@@ -189,11 +174,6 @@ function resolveListAccess(
189
174
  }
190
175
 
191
176
  for (const col of sortableColumns) {
192
- if (RESERVED_LIST_PARAMS.has(col)) {
193
- throw new Error(
194
- `[bunderstack] sortableColumns cannot include reserved query param "${col}"`,
195
- )
196
- }
197
177
  if (!columns.includes(col)) {
198
178
  throw new Error(
199
179
  `[bunderstack] sortableColumns references unknown column "${col}"`,
@@ -19,6 +19,7 @@ import {
19
19
  type TableAccessInput,
20
20
  } from '../access'
21
21
  import { createCrudOperations, type CrudOperations } from '../crud-operations'
22
+ import { MAX_LIST_LIMIT } from '../list-query'
22
23
  import { createApiBuilder } from './builder'
23
24
 
24
25
  export type CrudApiRouterOptions<
@@ -37,6 +38,40 @@ function strictObject<TEntries extends v.ObjectEntries>(schema: {
37
38
 
38
39
  type CrudInsert<TTable extends Table> = Partial<TTable['$inferInsert']>
39
40
 
41
+ /** A filter accepts one value (`=`), a list (`IN`), or null (`IS NULL`). */
42
+ export type ListFilterValue<T> = T | readonly T[] | null | 'null'
43
+
44
+ export type ListFilters<
45
+ TTable extends Table,
46
+ TFilterable extends string,
47
+ > = {
48
+ [K in Extract<keyof TTable['$inferSelect'], TFilterable>]?: ListFilterValue<
49
+ TTable['$inferSelect'][K]
50
+ >
51
+ }
52
+
53
+ /**
54
+ * The `list` input as callers see it. Filter columns and sortable columns come
55
+ * from the table's `access` entry, so `filters` autocompletes to real columns
56
+ * and rejects wrong value types at compile time.
57
+ */
58
+ export type ListInputFor<
59
+ TTable extends Table,
60
+ TFilterable extends string,
61
+ TSortable extends string,
62
+ > =
63
+ | {
64
+ limit?: number
65
+ offset?: number
66
+ cursor?: string
67
+ sort?: TSortable
68
+ order?: 'asc' | 'desc'
69
+ q?: string
70
+ count?: boolean
71
+ filters?: ListFilters<TTable, TFilterable>
72
+ }
73
+ | undefined
74
+
40
75
  export type BuildTableCrudProceduresArgs<
41
76
  TSchema extends Record<string, unknown>,
42
77
  TTable extends Table,
@@ -50,6 +85,8 @@ export type BuildTableCrudProceduresArgs<
50
85
  export function buildTableCrudProcedures<
51
86
  TSchema extends Record<string, unknown>,
52
87
  TTable extends Table,
88
+ TFilterable extends string = string,
89
+ TSortable extends string = string,
53
90
  >(args: BuildTableCrudProceduresArgs<TSchema, TTable>) {
54
91
  const { table, operations, builder, access } = args
55
92
  const name = getTableName(table)
@@ -89,24 +126,52 @@ export function buildTableCrudProcedures<
89
126
  body: strictObject(updateBodySchema),
90
127
  })
91
128
 
92
- const listQuerySchema = v.optional(
93
- v.strictObject({
94
- limit: v.optional(
129
+ // One filter field per allowed column, typed by the column itself: a scalar
130
+ // for `=`, a list for `IN`, and `null` for `IS NULL`. Query strings are
131
+ // coerced to these types by SmartCoercionHandlerPlugin, so REST and RPC share
132
+ // one contract and nothing has to re-read the raw URL.
133
+ const selectEntries = createSelectSchema(table).entries as Record<
134
+ string,
135
+ v.BaseSchema<unknown, unknown, v.BaseIssue<unknown>>
136
+ >
137
+ const filterEntries: v.ObjectEntries = {}
138
+ for (const column of access.filterableColumns) {
139
+ const base = selectEntries[column]
140
+ if (!base) continue
141
+ filterEntries[column] = v.optional(
142
+ v.union([
143
+ // `?filters[col]=null` — a query string cannot carry a real null.
95
144
  v.pipe(
96
- v.union([v.string(), v.number()]),
97
- v.transform(Number),
98
- v.number(),
145
+ v.literal('null'),
146
+ v.transform(() => null),
99
147
  ),
100
- ),
101
- offset: v.optional(v.number()),
148
+ base,
149
+ v.pipe(v.array(base), v.maxLength(MAX_LIST_LIMIT)),
150
+ v.null(),
151
+ ]),
152
+ )
153
+ }
154
+
155
+ // Built from runtime column lists, so the schema's own inferred type cannot
156
+ // name the columns; the cast restates it with the literals the caller's
157
+ // `access` config carries. Runtime shape and this type are the same object.
158
+ const listQuerySchema = v.optional(
159
+ v.strictObject({
160
+ limit: v.optional(v.pipe(v.number(), v.integer(), v.minValue(1))),
161
+ offset: v.optional(v.pipe(v.number(), v.integer(), v.minValue(0))),
102
162
  cursor: v.optional(v.string()),
103
- sort: v.optional(v.string()),
163
+ sort: v.optional(v.picklist(access.sortableColumns)),
104
164
  order: v.optional(v.picklist(['asc', 'desc'])),
105
- q: v.optional(v.string()),
165
+ q: v.optional(v.pipe(v.string(), v.maxLength(100))),
106
166
  count: v.optional(v.boolean()),
107
- filters: v.optional(v.record(v.string(), v.unknown())),
167
+ // Always present, even with no filterable columns: clients send `{}`.
168
+ filters: v.optional(v.strictObject(filterEntries)),
108
169
  }),
109
- )
170
+ ) as unknown as v.GenericSchema<
171
+ ListInputFor<TTable, TFilterable, TSortable>,
172
+ ListInputFor<TTable, TFilterable, TSortable>
173
+ >
174
+
110
175
 
111
176
  const listOutputSchema = v.strictObject({
112
177
  items: v.array(selectSchema),
@@ -138,16 +203,7 @@ export function buildTableCrudProcedures<
138
203
  user: session.user,
139
204
  session: { activeOrganizationId: session.activeOrganizationId },
140
205
  }
141
- const { filters, count, ...query } = input ?? {}
142
- const result = await operations.list(
143
- name,
144
- {
145
- ...query,
146
- ...(filters ?? {}),
147
- ...(count === undefined ? {} : { count: String(count) }),
148
- },
149
- execCtx,
150
- )
206
+ const result = await operations.list(name, input ?? {}, execCtx)
151
207
  return {
152
208
  ...result,
153
209
  items: result.items as TTable['$inferSelect'][],
@@ -273,8 +329,17 @@ export function buildTableCrudProcedures<
273
329
  }
274
330
  }
275
331
 
276
- export type TableCrudProcedures<TTable extends Table> = ReturnType<
277
- typeof buildTableCrudProcedures<Record<string, unknown>, TTable>
332
+ export type TableCrudProcedures<
333
+ TTable extends Table,
334
+ TFilterable extends string = string,
335
+ TSortable extends string = string,
336
+ > = ReturnType<
337
+ typeof buildTableCrudProcedures<
338
+ Record<string, unknown>,
339
+ TTable,
340
+ TFilterable,
341
+ TSortable
342
+ >
278
343
  >
279
344
 
280
345
  export function buildCrudApiRouter<
package/src/api/types.ts CHANGED
@@ -49,13 +49,34 @@ export type ExposedApiTables<TSchema, TAccess> = [TAccess] extends [undefined]
49
49
  DisabledKeys<TAccess> | (keyof TAccess & string)
50
50
  >
51
51
 
52
+ /**
53
+ * Column allowlists declared in `access` reach the client as literal unions, so
54
+ * `list` can type `filters` and `sort` per table. Tables that declare nothing
55
+ * get `never` filters and `'id'` sorting, matching the runtime defaults.
56
+ */
57
+ type FilterableOf<TAccess, K extends string> = K extends keyof TAccess
58
+ ? TAccess[K] extends { filterableColumns: readonly (infer F extends string)[] }
59
+ ? F
60
+ : never
61
+ : never
62
+
63
+ type SortableOf<TAccess, K extends string> = K extends keyof TAccess
64
+ ? TAccess[K] extends { sortableColumns: readonly (infer S extends string)[] }
65
+ ? S
66
+ : 'id'
67
+ : 'id'
68
+
52
69
  export type CrudApiRouterFor<
53
70
  TSchema extends Record<string, unknown>,
54
71
  TAccess = undefined,
55
72
  > = {
56
73
  [K in ExposedApiTables<TSchema, TAccess> as TSchema[K] extends Table
57
74
  ? K
58
- : never]: TableCrudProcedures<Extract<TSchema[K], Table>>
75
+ : never]: TableCrudProcedures<
76
+ Extract<TSchema[K], Table>,
77
+ FilterableOf<TAccess, K>,
78
+ SortableOf<TAccess, K>
79
+ >
59
80
  }
60
81
 
61
82
  type IsProcedure<T> = T extends { '~orpc': unknown } ? true : false
@@ -17,7 +17,6 @@ import {
17
17
  tableEntryForName,
18
18
  type AccessUser,
19
19
  type ResolvedAccess,
20
- type ResolvedTableAccess,
21
20
  type ScopeMap,
22
21
  type ScopeResolver,
23
22
  } from './access'
@@ -34,7 +33,12 @@ import {
34
33
  storeIdempotency,
35
34
  type IdempotencyConfig,
36
35
  } from './idempotency'
37
- import { executeList, parseListParams, type ListResult } from './list-query'
36
+ import {
37
+ executeList,
38
+ resolveListParams,
39
+ type ListParamsInput,
40
+ type ListResult,
41
+ } from './list-query'
38
42
  import { buildScopeWhere } from './scope'
39
43
 
40
44
  export interface CrudExecutionContext {
@@ -49,8 +53,8 @@ function errorCodeForStatus(status: number): BunderstackErrorCode {
49
53
  if (status === 404) return 'NOT_FOUND'
50
54
  if (status === 409) return 'CONFLICT'
51
55
  if (status === 413) return 'PAYLOAD_TOO_LARGE'
52
- if (status === 429) return 'RATE_LIMITED'
53
- return 'VALIDATION_ERROR'
56
+ if (status === 429) return 'TOO_MANY_REQUESTS'
57
+ return 'BAD_REQUEST'
54
58
  }
55
59
 
56
60
  export class CrudOperationError extends BunderstackError {
@@ -165,7 +169,7 @@ export function createCrudOperations<
165
169
  if (!idCol) {
166
170
  throw new CrudOperationError(
167
171
  400,
168
- ErrorCode.VALIDATION_ERROR,
172
+ ErrorCode.BAD_REQUEST,
169
173
  `Table ${tableName} has no id column`,
170
174
  )
171
175
  }
@@ -175,7 +179,7 @@ export function createCrudOperations<
175
179
  return {
176
180
  async list(
177
181
  tableName: string,
178
- paramsInput: URL | Record<string, unknown> | undefined,
182
+ params: ListParamsInput | undefined,
179
183
  ctx: CrudExecutionContext,
180
184
  ): Promise<ListResult<Record<string, unknown>>> {
181
185
  const { table, tableAccess, idCol } = resolveTable(tableName)
@@ -193,29 +197,15 @@ export function createCrudOperations<
193
197
  )
194
198
  }
195
199
 
196
- let urlObj: URL
197
- if (paramsInput instanceof URL) {
198
- urlObj = paramsInput
199
- } else {
200
- urlObj = new URL(ctx.request.url || 'http://localhost')
201
- if (paramsInput) {
202
- for (const [k, v] of Object.entries(paramsInput)) {
203
- if (v !== undefined && v !== null) {
204
- urlObj.searchParams.set(k, String(v))
205
- }
206
- }
207
- }
208
- }
209
-
210
200
  try {
211
- const params = parseListParams(urlObj, tableAccess)
201
+ const resolved = resolveListParams(params ?? {}, tableAccess)
212
202
  const scope = scopeFor(tableAccess.readScope, ctx)
213
203
  const scopeWhere = scope ? buildScopeWhere(table, scope) : undefined
214
204
  return await executeList(
215
205
  db,
216
206
  table,
217
207
  tableAccess,
218
- params,
208
+ resolved,
219
209
  idCol,
220
210
  scopeWhere,
221
211
  )
@@ -290,7 +280,7 @@ export function createCrudOperations<
290
280
  if (!isRecord(body)) {
291
281
  throw new CrudOperationError(
292
282
  400,
293
- ErrorCode.VALIDATION_ERROR,
283
+ ErrorCode.BAD_REQUEST,
294
284
  'Invalid JSON body',
295
285
  )
296
286
  }
@@ -407,7 +397,7 @@ export function createCrudOperations<
407
397
  if (!isRecord(body)) {
408
398
  throw new CrudOperationError(
409
399
  400,
410
- ErrorCode.VALIDATION_ERROR,
400
+ ErrorCode.BAD_REQUEST,
411
401
  'Invalid JSON body',
412
402
  )
413
403
  }
@@ -422,7 +412,7 @@ export function createCrudOperations<
422
412
  if (Object.keys(values).length === 0) {
423
413
  throw new CrudOperationError(
424
414
  400,
425
- ErrorCode.VALIDATION_ERROR,
415
+ ErrorCode.BAD_REQUEST,
426
416
  'No writable fields to update',
427
417
  )
428
418
  }
package/src/email/smtp.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import nodemailer from 'nodemailer'
2
2
 
3
- import type { EmailAdapter, EmailMessage } from '../email'
3
+ import type { EmailAdapter } from '../email'
4
4
 
5
5
  type SmtpTransport = {
6
6
  sendMail(message: Record<string, unknown>): Promise<{ messageId?: string }>
package/src/errors.ts CHANGED
@@ -1,30 +1,31 @@
1
- import { ORPCError, os, type ErrorMap } from '@orpc/server'
1
+ import {
2
+ COMMON_ERROR_STATUS_MAP,
3
+ ORPCError,
4
+ os,
5
+ type ErrorMap,
6
+ } from '@orpc/server'
2
7
  import * as v from 'valibot'
3
8
 
4
9
  import { StandardSchemaValidationError } from './standard-schema'
5
10
 
11
+ /**
12
+ * Every code we raise is one oRPC already knows, so handlers derive the HTTP
13
+ * status from `COMMON_ERROR_STATUS_MAP` on their own and clients can use the
14
+ * standard `isDefinedError` helpers. Do not add a code oRPC has no status for —
15
+ * it would silently answer 500.
16
+ */
6
17
  export const BUNDERSTACK_ERROR_CODES = [
7
- 'VALIDATION_ERROR',
18
+ 'BAD_REQUEST',
8
19
  'UNAUTHORIZED',
9
20
  'FORBIDDEN',
10
21
  'NOT_FOUND',
11
22
  'CONFLICT',
12
23
  'PAYLOAD_TOO_LARGE',
13
- 'RATE_LIMITED',
14
- ] as const
24
+ 'TOO_MANY_REQUESTS',
25
+ ] as const satisfies readonly (keyof typeof COMMON_ERROR_STATUS_MAP)[]
15
26
 
16
27
  export type BunderstackErrorCode = (typeof BUNDERSTACK_ERROR_CODES)[number]
17
28
 
18
- export const BUNDERSTACK_ERROR_STATUS_MAP = {
19
- VALIDATION_ERROR: 400,
20
- UNAUTHORIZED: 401,
21
- FORBIDDEN: 403,
22
- NOT_FOUND: 404,
23
- CONFLICT: 409,
24
- PAYLOAD_TOO_LARGE: 413,
25
- RATE_LIMITED: 429,
26
- } as const satisfies Record<BunderstackErrorCode, number>
27
-
28
29
  function errorDataSchema<const TCode extends BunderstackErrorCode>(
29
30
  code: TCode,
30
31
  ) {
@@ -35,13 +36,13 @@ function errorDataSchema<const TCode extends BunderstackErrorCode>(
35
36
  }
36
37
 
37
38
  export const BUNDERSTACK_ERRORS = {
38
- VALIDATION_ERROR: { data: errorDataSchema('VALIDATION_ERROR') },
39
+ BAD_REQUEST: { data: errorDataSchema('BAD_REQUEST') },
39
40
  UNAUTHORIZED: { data: errorDataSchema('UNAUTHORIZED') },
40
41
  FORBIDDEN: { data: errorDataSchema('FORBIDDEN') },
41
42
  NOT_FOUND: { data: errorDataSchema('NOT_FOUND') },
42
43
  CONFLICT: { data: errorDataSchema('CONFLICT') },
43
44
  PAYLOAD_TOO_LARGE: { data: errorDataSchema('PAYLOAD_TOO_LARGE') },
44
- RATE_LIMITED: { data: errorDataSchema('RATE_LIMITED') },
45
+ TOO_MANY_REQUESTS: { data: errorDataSchema('TOO_MANY_REQUESTS') },
45
46
  } as const satisfies ErrorMap
46
47
 
47
48
  export class BunderstackError extends Error {
@@ -55,7 +56,7 @@ export class BunderstackError extends Error {
55
56
  ) {
56
57
  super(message, options)
57
58
  this.name = 'BunderstackError'
58
- this.status = BUNDERSTACK_ERROR_STATUS_MAP[code]
59
+ this.status = COMMON_ERROR_STATUS_MAP[code]
59
60
  }
60
61
  }
61
62
 
@@ -68,7 +69,7 @@ export const mapBunderstackErrors = os
68
69
  const mapped =
69
70
  error instanceof StandardSchemaValidationError
70
71
  ? new BunderstackError(
71
- 'VALIDATION_ERROR',
72
+ 'BAD_REQUEST',
72
73
  error.message,
73
74
  error.issues,
74
75
  { cause: error },
@@ -87,14 +88,18 @@ export const mapBunderstackErrors = os
87
88
  }
88
89
  })
89
90
 
90
- /** @deprecated Legacy internal error codes retained for list-query compatibility. */
91
+ /**
92
+ * Sub-codes carried in `data.details.code` when the oRPC code alone loses
93
+ * information the client may want to branch on. Codes that duplicate an oRPC
94
+ * code are suppressed as redundant, see {@link CrudOperationError}.
95
+ */
91
96
  export const ErrorCode = {
92
- VALIDATION_ERROR: 'VALIDATION_ERROR',
97
+ BAD_REQUEST: 'BAD_REQUEST',
93
98
  FORBIDDEN: 'FORBIDDEN',
94
99
  NOT_FOUND: 'NOT_FOUND',
95
100
  CONFLICT: 'CONFLICT',
96
101
  INVALID_CURSOR: 'INVALID_CURSOR',
97
- RATE_LIMITED: 'RATE_LIMITED',
102
+ TOO_MANY_REQUESTS: 'TOO_MANY_REQUESTS',
98
103
  IDEMPOTENCY_REPLAY: 'IDEMPOTENCY_REPLAY',
99
104
  IDEMPOTENCY_CONFLICT: 'IDEMPOTENCY_CONFLICT',
100
105
  } as const
@@ -107,7 +112,7 @@ export class ListQueryError extends Error {
107
112
 
108
113
  constructor(
109
114
  message: string,
110
- code: ErrorCodeValue = ErrorCode.VALIDATION_ERROR,
115
+ code: ErrorCodeValue = ErrorCode.BAD_REQUEST,
111
116
  details?: unknown,
112
117
  ) {
113
118
  super(message)
package/src/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { AnyRouter as AnyORPCRouter } from '@orpc/server'
2
2
  // src/index.ts
3
3
 
4
+ import { SmartCoercionHandlerPlugin } from '@orpc/json-schema'
4
5
  import { OpenAPIGenerator, OpenAPIGeneratorError } from '@orpc/openapi'
5
6
  import { OpenAPIHandler } from '@orpc/openapi/fetch'
6
7
  import { RPCHandler } from '@orpc/server/fetch'
@@ -9,7 +10,6 @@ import { ValibotToJsonSchemaConverter } from '@orpc/valibot'
9
10
  import type { TableAccessInput } from './access'
10
11
  import type {
11
12
  CrudApiRouterFor,
12
- ExposedApiTables,
13
13
  MergeApiRouterTypes,
14
14
  UnifiedApiRouter,
15
15
  } from './api/types'
@@ -36,8 +36,6 @@ import { buildApiRouter } from './api/router'
36
36
  import { buildStorageApiRouter } from './api/storage-router'
37
37
  import {
38
38
  buildApiRegistry,
39
- mergeApiRoutersStrict,
40
- normalizeApiPath,
41
39
  normalizeForeignOpenAPISpec,
42
40
  } from './api/registry'
43
41
  import {
@@ -51,7 +49,6 @@ import { createDb } from './db'
51
49
  import { detectDialect } from './dialect'
52
50
  import { createEmail, emailProviderTag, type EmailFacade } from './email'
53
51
  import { validateEnv, type EnvConfigInput, type ValidatedEnv } from './env'
54
- import { BUNDERSTACK_ERROR_STATUS_MAP } from './errors'
55
52
  import { buildHandler } from './handler'
56
53
  import { withInternalTables } from './internal-tables'
57
54
  import {
@@ -348,6 +345,7 @@ export async function createBunderstack<
348
345
  const realtime = createRealtimeFacade<TSchema>(
349
346
  publisher,
350
347
  runtimeRealtimeTransport,
348
+ options.schema,
351
349
  )
352
350
  const registry = createBucketStorages(config.storage)
353
351
  const storageOperations = createStorageOperations({
@@ -410,7 +408,7 @@ export async function createBunderstack<
410
408
  // ordinary cron now, so it inherits retries, timeout and onFailed.
411
409
  const resolvedDefs: JobsDefs | undefined = storageConfigured
412
410
  ? {
413
- ...(jobsDefs ?? {}),
411
+ ...jobsDefs,
414
412
  'bunderstack:storage-sweep': {
415
413
  kind: 'cron',
416
414
  schedule: '0 4 * * *',
@@ -546,11 +544,13 @@ export async function createBunderstack<
546
544
  ]),
547
545
  })
548
546
 
547
+ const valibotConverter = new ValibotToJsonSchemaConverter()
548
+
549
549
  const combinedOpenAPISpec = options.openapi
550
550
  ? mergeOpenAPISpecs({
551
551
  nativeSpec: await new OpenAPIGenerator({
552
552
  converters: [
553
- new ValibotToJsonSchemaConverter(),
553
+ valibotConverter,
554
554
  {
555
555
  condition: (schema: any) =>
556
556
  Boolean(
@@ -571,13 +571,22 @@ export async function createBunderstack<
571
571
  : undefined
572
572
 
573
573
  const openapiHandler = new OpenAPIHandler(nativeRouter, {
574
- errorStatusMap: BUNDERSTACK_ERROR_STATUS_MAP,
574
+ // Query strings and form bodies are strings; this coerces them to the
575
+ // types each procedure's input schema declares, so schemas stay honest
576
+ // (`v.number()`, not a string-union pipe) and REST matches RPC.
577
+ plugins: [
578
+ new SmartCoercionHandlerPlugin({ converters: [valibotConverter] }),
579
+ ],
575
580
  customErrorResponseBodyEncoder: (error: any) => ({
576
581
  error: error.message,
577
582
  code: error.data?.code ?? error.code,
583
+ // oRPC reports schema failures as `data.issues`; forwarding them tells
584
+ // the client which field was rejected instead of just "invalid".
578
585
  ...(error.data?.details !== undefined
579
586
  ? { details: error.data.details }
580
- : {}),
587
+ : error.data?.issues !== undefined
588
+ ? { details: error.data.issues }
589
+ : {}),
581
590
  }),
582
591
  fetchInterceptors: [
583
592
  async (options) => {
@@ -806,6 +815,9 @@ export type {
806
815
 
807
816
  export { createApiBuilder } from './api/builder'
808
817
  export type { BunderstackApiBuilder, ApiFactory } from './api/builder'
818
+ // Needed to declare shared middleware over the app's context, e.g.
819
+ // `os.$context<ApiContext<typeof schema>>().middleware(...)`.
820
+ export type { ApiContext } from './api/context'
809
821
  export type {
810
822
  CrudApiRouterFor,
811
823
  ExposedApiTables,
package/src/list-query.ts CHANGED
@@ -21,18 +21,28 @@ import type { AnyDb } from './dialect'
21
21
 
22
22
  import { ErrorCode, ListQueryError } from './errors'
23
23
 
24
- /** Caps both `?limit=` and the number of values in a comma-separated `IN` filter. */
24
+ /** Caps both `?limit=` and the number of values in an `IN` filter. */
25
25
  export const MAX_LIST_LIMIT = 200
26
26
 
27
- export const RESERVED_LIST_PARAMS = new Set([
28
- 'limit',
29
- 'offset',
30
- 'sort',
31
- 'order',
32
- 'q',
33
- 'cursor',
34
- 'count',
35
- ])
27
+ /** Default page size when a request omits `limit`. */
28
+ export const DEFAULT_LIST_LIMIT = 20
29
+
30
+ /**
31
+ * What a list procedure accepts. Shape and column types are enforced by the
32
+ * generated input schema, so by the time these params arrive they are already
33
+ * validated and coerced — this module only applies policy (defaults, the limit
34
+ * cap, cursor rules).
35
+ */
36
+ export type ListParamsInput = {
37
+ limit?: number
38
+ offset?: number
39
+ cursor?: string
40
+ sort?: string
41
+ order?: SortOrder
42
+ q?: string
43
+ count?: boolean
44
+ filters?: Record<string, unknown>
45
+ }
36
46
 
37
47
  export type ParsedListParams = {
38
48
  limit: number
@@ -81,76 +91,22 @@ function isCursorPayload(value: unknown): value is CursorPayload {
81
91
  )
82
92
  }
83
93
 
84
- function parseBoolean(value: string | undefined): boolean {
85
- if (!value) return false
86
- const v = value.toLowerCase()
87
- return v === 'true' || v === '1'
88
- }
89
-
90
- function parseLimit(raw: string | undefined): number {
91
- if (raw === undefined || raw === '') return 20
92
- const n = Number(raw)
93
- if (!Number.isInteger(n) || n < 1) {
94
- throw new ListQueryError(
95
- `limit must be an integer between 1 and ${MAX_LIST_LIMIT}`,
96
- )
97
- }
98
- return Math.min(n, MAX_LIST_LIMIT)
99
- }
100
-
101
- function parseOffset(raw: string | undefined): number {
102
- if (raw === undefined || raw === '') return 0
103
- const n = Number(raw)
104
- if (!Number.isInteger(n) || n < 0) {
105
- throw new ListQueryError('offset must be a non-negative integer')
106
- }
107
- return n
108
- }
109
-
110
- function parseOrder(raw: string | undefined): SortOrder {
111
- if (!raw || raw === 'asc') return 'asc'
112
- if (raw === 'desc') return 'desc'
113
- throw new ListQueryError('order must be "asc" or "desc"')
114
- }
115
-
116
- export function parseListParams(
117
- url: URL,
94
+ /**
95
+ * Applies list policy to already-validated input: defaults, the limit cap, and
96
+ * the rules a schema cannot express (cursor excludes offset, and a cursor must
97
+ * agree with the sort it was minted for).
98
+ */
99
+ export function resolveListParams(
100
+ input: ListParamsInput,
118
101
  access: ResolvedTableAccess,
119
102
  ): ParsedListParams {
120
- const params = url.searchParams
121
- const limit = parseLimit(params.get('limit') ?? undefined)
122
- const cursor = params.get('cursor')?.trim() || undefined
123
- const hasOffset = params.has('offset') && params.get('offset') !== ''
124
- const offset = hasOffset
125
- ? parseOffset(params.get('offset') ?? undefined)
126
- : undefined
127
-
128
- if (cursor && hasOffset) {
103
+ const cursor = input.cursor?.trim() || undefined
104
+ if (cursor && input.offset !== undefined) {
129
105
  throw new ListQueryError('cursor and offset cannot be used together')
130
106
  }
131
107
 
132
- const sort = params.get('sort')?.trim() || access.defaultSort.column
133
- const order = params.has('order')
134
- ? parseOrder(params.get('order') ?? undefined)
135
- : params.has('sort')
136
- ? 'asc'
137
- : access.defaultSort.order
138
-
139
- if (!access.sortableColumns.includes(sort)) {
140
- throw new ListQueryError(`sort column "${sort}" is not allowed`)
141
- }
142
-
143
- const filters: Record<string, unknown> = {}
144
- for (const [key, value] of params.entries()) {
145
- if (RESERVED_LIST_PARAMS.has(key)) continue
146
- if (!access.filterableColumns.includes(key)) {
147
- throw new ListQueryError(`filter column "${key}" is not allowed`)
148
- }
149
- filters[key] = value === 'null' ? null : value
150
- }
151
-
152
- const q = params.get('q')?.trim().slice(0, 100) ?? ''
153
- const count = parseBoolean(params.get('count') ?? undefined)
108
+ const sort = input.sort ?? access.defaultSort.column
109
+ const order = input.order ?? (input.sort ? 'asc' : access.defaultSort.order)
154
110
 
155
111
  if (cursor) {
156
112
  const decoded = decodeCursor(cursor)
@@ -163,14 +119,14 @@ export function parseListParams(
163
119
  }
164
120
 
165
121
  return {
166
- limit,
167
- offset: cursor ? undefined : (offset ?? 0),
122
+ limit: Math.min(input.limit ?? DEFAULT_LIST_LIMIT, MAX_LIST_LIMIT),
123
+ offset: cursor ? undefined : (input.offset ?? 0),
168
124
  sort,
169
125
  order,
170
- q,
126
+ q: input.q?.trim() ?? '',
171
127
  cursor,
172
- count,
173
- filters,
128
+ count: input.count ?? false,
129
+ filters: input.filters ?? {},
174
130
  }
175
131
  }
176
132
 
@@ -191,41 +147,20 @@ function buildSearchWhere(
191
147
  return conditions.length ? or(...conditions) : undefined
192
148
  }
193
149
 
194
- function coerceFilterValue(
150
+ /**
151
+ * Cursors carry their sort value as JSON, so a date column arrives as a string
152
+ * and has to be rebuilt. Filter values need no such repair — the generated
153
+ * input schema already types them.
154
+ */
155
+ function coerceCursorValue(
195
156
  table: Parameters<typeof getTableColumns>[0],
196
157
  columnName: string,
197
- raw: unknown,
158
+ raw: string | number | null,
198
159
  ): unknown {
199
160
  if (raw === null) return null
200
161
  const col = getTableColumns(table)[columnName]
201
- if (!col) return raw
202
-
203
- const dataType = col.dataType
204
- if (
205
- dataType === 'number' ||
206
- dataType === 'integer' ||
207
- dataType === 'bigint'
208
- ) {
209
- const n = Number(raw)
210
- if (Number.isNaN(n)) {
211
- throw new ListQueryError(`filter "${columnName}" must be a number`)
212
- }
213
- return n
214
- }
215
- if (dataType === 'boolean') {
216
- const s = String(raw).toLowerCase()
217
- if (s === 'true' || s === '1') return true
218
- if (s === 'false' || s === '0') return false
219
- throw new ListQueryError(`filter "${columnName}" must be a boolean`)
220
- }
221
- if (dataType === 'date') {
222
- const d = new Date(raw as string | number)
223
- if (Number.isNaN(d.getTime())) {
224
- throw new ListQueryError(`filter "${columnName}" must be a valid date`)
225
- }
226
- return d
227
- }
228
- return String(raw)
162
+ if (col?.dataType === 'date') return new Date(raw)
163
+ return raw
229
164
  }
230
165
 
231
166
  function buildFilterWhere(
@@ -235,32 +170,14 @@ function buildFilterWhere(
235
170
  const columns = getTableColumns(table)
236
171
  const conditions: SQL[] = []
237
172
 
238
- for (const [name, raw] of Object.entries(filters)) {
173
+ for (const [name, value] of Object.entries(filters)) {
239
174
  const col = columns[name]
240
- if (!col) continue
241
-
242
- if (raw === null) {
243
- conditions.push(sql`${col} IS NULL`)
244
- continue
245
- }
246
-
247
- // `?column=a,b,c` — TypeIDs and other filterable values never contain
248
- // commas, so this is a safe, zero-syntax way to do `column IN (...)`.
249
- if (typeof raw === 'string' && raw.includes(',')) {
250
- const parts = raw.split(',').filter((p) => p.length > 0)
251
- if (parts.length > MAX_LIST_LIMIT) {
252
- throw new ListQueryError(
253
- `filter "${name}" accepts at most ${MAX_LIST_LIMIT} comma-separated values`,
254
- )
255
- }
256
- const values = parts.map((p) => coerceFilterValue(table, name, p))
257
- conditions.push(inArray(col, values))
258
- continue
259
- }
175
+ if (!col || value === undefined) continue
260
176
 
261
- const value = coerceFilterValue(table, name, raw)
262
177
  if (value === null) {
263
178
  conditions.push(sql`${col} IS NULL`)
179
+ } else if (Array.isArray(value)) {
180
+ if (value.length) conditions.push(inArray(col, value))
264
181
  } else {
265
182
  conditions.push(eq(col, value))
266
183
  }
@@ -301,7 +218,7 @@ function buildCursorWhere(
301
218
  ): SQL {
302
219
  const columns = getTableColumns(table)
303
220
  const sortCol = columns[sortColName]!
304
- const sortValue = coerceFilterValue(table, sortColName, cursor.v)
221
+ const sortValue = coerceCursorValue(table, sortColName, cursor.v)
305
222
 
306
223
  if (order === 'desc') {
307
224
  return or(
package/src/rate-limit.ts CHANGED
@@ -54,7 +54,7 @@ export function createRateLimiter(
54
54
  return new Response(
55
55
  JSON.stringify({
56
56
  error: 'Too many requests',
57
- code: 'RATE_LIMITED',
57
+ code: 'TOO_MANY_REQUESTS',
58
58
  }),
59
59
  {
60
60
  status: 429,
@@ -1,4 +1,9 @@
1
- import { getTableName, type InferSelectModel, type Table } from 'drizzle-orm'
1
+ import {
2
+ getTableName,
3
+ isTable,
4
+ type InferSelectModel,
5
+ type Table,
6
+ } from 'drizzle-orm'
2
7
 
3
8
  import type {
4
9
  RealtimeAction,
@@ -25,9 +30,17 @@ export interface RealtimeFacade<
25
30
  ): Promise<void>
26
31
  }
27
32
 
33
+ /**
34
+ * One name for a table everywhere: events, subscriptions, and the CRUD router
35
+ * all use the schema key. Pass the schema so a key like `creditBalances` is not
36
+ * published as its SQL name `credit_balances` — clients subscribe with the key
37
+ * they call procedures with. Without a schema, the SQL name is the only name
38
+ * available and is used as-is.
39
+ */
28
40
  export function createRealtimeFacade<TSchema extends Record<string, unknown>>(
29
41
  publisher?: RealtimePublisher,
30
42
  transport: RealtimeTransport = publisher ? 'memory' : 'disabled',
43
+ schema?: TSchema,
31
44
  ): RealtimeFacade<TSchema> {
32
45
  if (!publisher && transport !== 'disabled') {
33
46
  throw new Error(
@@ -40,13 +53,19 @@ export function createRealtimeFacade<TSchema extends Record<string, unknown>>(
40
53
  )
41
54
  }
42
55
 
56
+ const keyByTableName = new Map<string, string>()
57
+ for (const [key, value] of Object.entries(schema ?? {})) {
58
+ if (isTable(value)) keyByTableName.set(getTableName(value), key)
59
+ }
60
+
43
61
  return {
44
62
  enabled: publisher !== undefined,
45
63
  transport,
46
64
  async publish(table, action, record) {
47
65
  if (!publisher) return
66
+ const tableName = getTableName(table)
48
67
  await publisher.publish('change', {
49
- table: getTableName(table),
68
+ table: keyByTableName.get(tableName) ?? tableName,
50
69
  action,
51
70
  record: record as unknown as Record<string, unknown>,
52
71
  })
@@ -33,7 +33,11 @@ export async function* filterRealtimeChanges(
33
33
  const getSession = () => (sessionPromise ??= options.getSession())
34
34
 
35
35
  for await (const change of source) {
36
- const entry = tableEntryForName(options.access, change.table)
36
+ // Events name tables by schema key; the SQL-name lookup stays as a fallback
37
+ // for publishers outside the CRUD path that only know the physical name.
38
+ const entry =
39
+ options.access.get(change.table) ??
40
+ tableEntryForName(options.access, change.table)
37
41
  if (!entry?.enabled) continue
38
42
 
39
43
  const recordId = change.record.id
@@ -187,7 +187,7 @@ export function createStorageOperations(options: StorageOperationsOptions) {
187
187
 
188
188
  if (!matchMime(file.type, bucket.upload?.accept)) {
189
189
  throw new BunderstackError(
190
- 'VALIDATION_ERROR',
190
+ 'BAD_REQUEST',
191
191
  `Content type ${file.type || '(none)'} not allowed`,
192
192
  )
193
193
  }
@@ -255,7 +255,7 @@ export function createStorageOperations(options: StorageOperationsOptions) {
255
255
  await adapter.delete(fileId)
256
256
  await deleteFileMetaRow(db, fileId)
257
257
  throw new BunderstackError(
258
- validation ? 'VALIDATION_ERROR' : 'PAYLOAD_TOO_LARGE',
258
+ validation ? 'BAD_REQUEST' : 'PAYLOAD_TOO_LARGE',
259
259
  message,
260
260
  )
261
261
  }
@@ -308,7 +308,7 @@ export function createStorageOperations(options: StorageOperationsOptions) {
308
308
  if (spec) {
309
309
  if (!bucket.transforms) {
310
310
  throw new BunderstackError(
311
- 'VALIDATION_ERROR',
311
+ 'BAD_REQUEST',
312
312
  'Transforms not enabled for this bucket',
313
313
  )
314
314
  }
@@ -1,106 +0,0 @@
1
- import type { InferRouterInputs, InferRouterOutputs } from '@orpc/server'
2
-
3
- import { pgTable, text } from 'drizzle-orm/pg-core'
4
- import * as v from 'valibot'
5
-
6
- import type { ExposedApiTables } from './types'
7
-
8
- import { pglite } from '../database/pglite'
9
- import { createBunderstack } from '../index'
10
-
11
- type Equal<A, B> =
12
- (<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2
13
- ? true
14
- : false
15
- type Expect<T extends true> = T
16
-
17
- const posts = pgTable('posts', {
18
- id: text('id').primaryKey(),
19
- title: text('title').notNull(),
20
- })
21
-
22
- const privateNotes = pgTable('private_notes', {
23
- id: text('id').primaryKey(),
24
- content: text('content').notNull(),
25
- })
26
-
27
- const ownedPosts = pgTable('owned_posts', {
28
- id: text('id').primaryKey(),
29
- userId: text('user_id').notNull(),
30
- })
31
-
32
- type ImplicitTables = ExposedApiTables<
33
- { posts: typeof posts; ownedPosts: typeof ownedPosts },
34
- undefined
35
- >
36
- type _ImplicitAccessHidesUnownedTable = Expect<
37
- Equal<'posts' extends ImplicitTables ? true : false, false>
38
- >
39
- type _ImplicitAccessIncludesConventionTable = Expect<
40
- Equal<'ownedPosts' extends ImplicitTables ? true : false, true>
41
- >
42
-
43
- const typedApp = await createBunderstack({
44
- schema: { posts, privateNotes },
45
- database: { adapter: pglite() },
46
- processEnv: {
47
- DATABASE_URL: 'memory://',
48
- BUNDERSTACK_ROLE: 'web',
49
- },
50
- access: {
51
- posts: { crud: true, list: 'public', create: 'public' },
52
- privateNotes: { crud: false },
53
- },
54
- api: (o) => ({
55
- stats: o.protected
56
- .input(v.object({ period: v.picklist(['day', 'week']) }))
57
- .output(
58
- v.object({ period: v.picklist(['day', 'week']), userId: v.string() }),
59
- )
60
- .handler(async ({ input, context }) => {
61
- const _userId: string = context.user.id
62
- const _db = context.db
63
- const _env = context.env
64
- return {
65
- period: input.period,
66
- userId: context.user.id,
67
- }
68
- }),
69
- }),
70
- })
71
-
72
- type Api = NonNullable<typeof typedApp.$inferClient>['api']
73
-
74
- type _HasPosts = Expect<Equal<'posts' extends keyof Api ? true : false, true>>
75
- type _HidesPrivateNotes = Expect<
76
- Equal<'privateNotes' extends keyof Api ? true : false, false>
77
- >
78
- type _HasStats = Expect<Equal<'stats' extends keyof Api ? true : false, true>>
79
-
80
- type PostsInputs = InferRouterInputs<Api>['posts']
81
- type PostsOutputs = InferRouterOutputs<Api>['posts']
82
- type IsAny<T> = 0 extends 1 & T ? true : false
83
- type ExpectedUpdateInput = {
84
- params: { id: string }
85
- query?: Record<string, unknown>
86
- headers?: Record<string, unknown>
87
- body: { title?: string }
88
- }
89
-
90
- type _CreateInput = Expect<
91
- Equal<
92
- PostsInputs['create'],
93
- Partial<typeof posts.$inferInsert>
94
- >
95
- >
96
- type _GetInput = Expect<Equal<PostsInputs['get'], { id: string }>>
97
- type _UpdateInputToExpected = Expect<
98
- PostsInputs['update'] extends ExpectedUpdateInput ? true : false
99
- >
100
- type _ExpectedToUpdateInput = Expect<
101
- ExpectedUpdateInput extends PostsInputs['update'] ? true : false
102
- >
103
- type _ListItems = Expect<
104
- Equal<PostsOutputs['list']['items'], Array<{ id: string; title: string }>>
105
- >
106
- type _GetOutputIsTyped = Expect<Equal<IsAny<PostsOutputs['get']>, false>>