bunderstack 0.16.0 → 0.17.0-beta.0

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/src/index.ts CHANGED
@@ -1,12 +1,19 @@
1
+ import type { AnyRouter as AnyORPCRouter } from '@orpc/server'
1
2
  // src/index.ts
2
- import type { AnyRouter } from '@trpc/server'
3
- import type { Hono as HonoType } from 'hono'
4
3
 
5
- import { fetchRequestHandler } from '@trpc/server/adapters/fetch'
6
-
7
- import { getTableName, isTable } from 'drizzle-orm'
4
+ import { OpenAPIGenerator, OpenAPIGeneratorError } from '@orpc/openapi'
5
+ import { OpenAPIHandler } from '@orpc/openapi/fetch'
6
+ import { RPCHandler } from '@orpc/server/fetch'
7
+ import { ValibotToJsonSchemaConverter } from '@orpc/valibot'
8
8
 
9
9
  import type { TableAccessInput } from './access'
10
+ import type {
11
+ CrudApiRouterFor,
12
+ ExposedApiTables,
13
+ MergeApiRouterTypes,
14
+ UnifiedApiRouter,
15
+ } from './api/types'
16
+ import type { RealtimeApiRouter } from './api/realtime-router'
10
17
  import type { DbFor } from './db'
11
18
  import type {
12
19
  BunderstackJobsBuilder,
@@ -19,11 +26,20 @@ import type {
19
26
  import type { StorageConfigInput } from './storage/buckets'
20
27
  import type { StorageAdapter } from './storage/index'
21
28
 
29
+ import { validateAndResolveAccess } from './access'
30
+ import { createApiBuilder } from './api/builder'
31
+ import { createApiContext } from './api/context'
32
+ import { buildCrudApiRouter } from './api/crud-router'
33
+ import { mergeOpenAPISpecs } from './api/openapi'
34
+ import { buildRealtimeApiRouter } from './api/realtime-router'
35
+ import { buildApiRouter } from './api/router'
36
+ import { buildStorageApiRouter } from './api/storage-router'
22
37
  import {
23
- resolveAccessUser,
24
- tableEntryForName,
25
- validateAndResolveAccess,
26
- } from './access'
38
+ buildApiRegistry,
39
+ mergeApiRoutersStrict,
40
+ normalizeApiPath,
41
+ normalizeForeignOpenAPISpec,
42
+ } from './api/registry'
27
43
  import {
28
44
  createAuth,
29
45
  toAuthSessionResolver,
@@ -31,11 +47,11 @@ import {
31
47
  } from './auth'
32
48
  import { resolveConfig, type BunderstackConfig } from './config'
33
49
  import { resolveRealtimeRedisUrl } from './config'
34
- import { buildCrudRouter } from './crud'
35
50
  import { createDb } from './db'
36
51
  import { detectDialect } from './dialect'
37
52
  import { createEmail, emailProviderTag, type EmailFacade } from './email'
38
53
  import { validateEnv, type EnvConfigInput, type ValidatedEnv } from './env'
54
+ import { BUNDERSTACK_ERROR_STATUS_MAP } from './errors'
39
55
  import { buildHandler } from './handler'
40
56
  import { withInternalTables } from './internal-tables'
41
57
  import {
@@ -56,19 +72,18 @@ import {
56
72
  type RealtimeFacade,
57
73
  type RealtimeTransport,
58
74
  } from './realtime/facade'
59
- import { createRealtimeBroker, buildRealtimeRouter } from './realtime/index'
60
- import { createRedisRealtimeBroker } from './realtime/redis'
61
- import { createRouteContext, validateCustomRoutes } from './routes'
75
+ import {
76
+ createMemoryRealtimePublisher,
77
+ createRedisRealtimePublisher,
78
+ } from './realtime/publisher'
62
79
  import { deleteFileWithDerivatives } from './storage/delete'
63
80
  import { deleteFileMetaRow, insertReadyFile } from './storage/file-meta'
64
81
  import { createBucketStorages } from './storage/registry'
65
- import { buildBucketStorageRouter } from './storage/router'
82
+ import { createStorageOperations } from './storage/operations'
66
83
  import { sweepOrphans } from './storage/sweep'
67
- import { createTRPC, type BunderstackTRPC } from './trpc'
68
84
 
69
85
  export type AuthInstance = ReturnType<typeof createAuth>
70
86
 
71
-
72
87
  function waitForWorkerShutdown(
73
88
  signal: AbortSignal,
74
89
  installProcessListeners: boolean,
@@ -153,16 +168,13 @@ export type BunderstackApp<
153
168
  TAccess extends Record<string, TableAccessInput> | undefined = undefined,
154
169
  TBuckets extends string = string,
155
170
  TEnv extends EnvConfigInput | undefined = undefined,
156
- TRouter = undefined,
157
171
  TJobsDefs extends JobsDefs | undefined = undefined,
172
+ TCustomApiRouter extends AnyORPCRouter | undefined = undefined,
158
173
  > = {
159
174
  handler: (req: Request) => Promise<Response>
160
175
  db: DbFor<TSchema>
161
176
  auth: AuthInstance
162
177
  storage: StorageFacade
163
- router: HonoType
164
- /** Raw tRPC router when the config declared one — escape hatch. */
165
- trpcRouter?: AnyRouter
166
178
  /** Validated env: bunderstack's base vars plus the config's `env` extension. */
167
179
  env: ValidatedEnv<TEnv>
168
180
  /** Email facade; always present — send() throws when email isn't configured. */
@@ -191,129 +203,40 @@ export type BunderstackApp<
191
203
  schema: TSchema
192
204
  access: TAccess
193
205
  buckets: TBuckets
194
- trpc: TRouter
206
+ api: MergeApiRouterTypes<
207
+ UnifiedApiRouter<CrudApiRouterFor<TSchema, TAccess>, TCustomApiRouter>,
208
+ RealtimeApiRouter
209
+ >
195
210
  }
196
211
  }
197
212
 
198
- // Overloads: the builder-callback form and the prebuilt-router/none form are
199
- // separate signatures so the callback's `t` parameter gets contextual typing
200
- // and the router type lands on `$inferClient` without conditional-type
201
- // inference (which breaks under contextual return types). `jobs` needs the
202
- // same split against BOTH trpc forms — a union parameter type (`TJobsDefs |
203
- // (callback => TJobsDefs)`) defeats inference (TS widens TJobsDefs to its
204
- // constraint when a function literal could match either union arm) — hence
205
- // four overloads covering the trpc × jobs option cross product.
206
- export function createBunderstack<
207
- TSchema extends Record<string, unknown>,
208
- const TAccess extends Record<string, TableAccessInput> | undefined =
209
- undefined,
210
- const TStorage extends StorageConfigInput | undefined = undefined,
211
- const TEnv extends EnvConfigInput | undefined = undefined,
212
- TRouter extends AnyRouter = AnyRouter,
213
- const TJobsDefs extends JobsDefs | undefined = undefined,
214
- >(
215
- options: BunderstackConfig<TSchema, TAccess, TStorage, TEnv> & {
216
- /** Builder callback receiving the pre-wired `t` instance. */
217
- trpc: (t: BunderstackTRPC<TSchema, ValidatedEnv<TEnv>>) => TRouter
218
- /** Builder callback receiving the pre-wired `j` instance. */
219
- jobs: (j: BunderstackJobsBuilder<TSchema, ValidatedEnv<TEnv>>) => TJobsDefs
220
- },
221
- ): Promise<
222
- BunderstackApp<
223
- TSchema,
224
- TAccess,
225
- BucketNamesOf<TStorage>,
226
- TEnv,
227
- TRouter,
228
- TJobsDefs
229
- >
230
- >
231
- export function createBunderstack<
232
- TSchema extends Record<string, unknown>,
233
- const TAccess extends Record<string, TableAccessInput> | undefined =
234
- undefined,
235
- const TStorage extends StorageConfigInput | undefined = undefined,
236
- const TEnv extends EnvConfigInput | undefined = undefined,
237
- TRouter extends AnyRouter = AnyRouter,
238
- const TJobsDefs extends JobsDefs | undefined = undefined,
239
- >(
240
- options: BunderstackConfig<TSchema, TAccess, TStorage, TEnv> & {
241
- /** Builder callback receiving the pre-wired `t` instance. */
242
- trpc: (t: BunderstackTRPC<TSchema, ValidatedEnv<TEnv>>) => TRouter
243
- /** Prebuilt job definitions (escape hatch for multi-file setups). */
244
- jobs?: TJobsDefs
245
- },
246
- ): Promise<
247
- BunderstackApp<
248
- TSchema,
249
- TAccess,
250
- BucketNamesOf<TStorage>,
251
- TEnv,
252
- TRouter,
253
- TJobsDefs
254
- >
255
- >
256
213
  export function createBunderstack<
257
214
  TSchema extends Record<string, unknown>,
258
- const TAccess extends Record<string, TableAccessInput> | undefined =
259
- undefined,
215
+ const TAccess extends Record<string, TableAccessInput> | undefined = undefined,
260
216
  const TStorage extends StorageConfigInput | undefined = undefined,
261
217
  const TEnv extends EnvConfigInput | undefined = undefined,
262
- TRouter extends AnyRouter | undefined = undefined,
263
218
  const TJobsDefs extends JobsDefs | undefined = undefined,
219
+ TCustomApiRouter extends AnyORPCRouter | undefined = undefined,
264
220
  >(
265
- options: BunderstackConfig<TSchema, TAccess, TStorage, TEnv> & {
266
- /** Prebuilt tRPC router (escape hatch for multi-file setups). */
267
- trpc?: TRouter
268
- /** Builder callback receiving the pre-wired `j` instance. */
269
- jobs: (j: BunderstackJobsBuilder<TSchema, ValidatedEnv<TEnv>>) => TJobsDefs
221
+ options: BunderstackConfig<TSchema, TAccess, TStorage, TEnv, TCustomApiRouter> & {
222
+ jobs?: TJobsDefs | ((j: BunderstackJobsBuilder<TSchema, ValidatedEnv<TEnv>>) => TJobsDefs)
270
223
  },
271
- ): Promise<
272
- BunderstackApp<
273
- TSchema,
274
- TAccess,
275
- BucketNamesOf<TStorage>,
276
- TEnv,
277
- TRouter,
278
- TJobsDefs
279
- >
280
- >
281
- export function createBunderstack<
224
+ ): Promise<BunderstackApp<TSchema, TAccess, BucketNamesOf<TStorage>, TEnv, TJobsDefs, TCustomApiRouter>>
225
+ export async function createBunderstack<
282
226
  TSchema extends Record<string, unknown>,
283
227
  const TAccess extends Record<string, TableAccessInput> | undefined =
284
228
  undefined,
285
229
  const TStorage extends StorageConfigInput | undefined = undefined,
286
230
  const TEnv extends EnvConfigInput | undefined = undefined,
287
- TRouter extends AnyRouter | undefined = undefined,
288
- const TJobsDefs extends JobsDefs | undefined = undefined,
231
+ TCustomApiRouter extends AnyORPCRouter | undefined = undefined,
289
232
  >(
290
- options: BunderstackConfig<TSchema, TAccess, TStorage, TEnv> & {
291
- /** Prebuilt tRPC router (escape hatch for multi-file setups). */
292
- trpc?: TRouter
293
- /** Prebuilt job definitions (escape hatch for multi-file setups). */
294
- jobs?: TJobsDefs
295
- },
296
- ): Promise<
297
- BunderstackApp<
233
+ options: BunderstackConfig<
298
234
  TSchema,
299
235
  TAccess,
300
- BucketNamesOf<TStorage>,
236
+ TStorage,
301
237
  TEnv,
302
- TRouter,
303
- TJobsDefs
304
- >
305
- >
306
- export async function createBunderstack<
307
- TSchema extends Record<string, unknown>,
308
- const TAccess extends Record<string, TableAccessInput> | undefined =
309
- undefined,
310
- const TStorage extends StorageConfigInput | undefined = undefined,
311
- const TEnv extends EnvConfigInput | undefined = undefined,
312
- >(
313
- options: BunderstackConfig<TSchema, TAccess, TStorage, TEnv> & {
314
- trpc?:
315
- | AnyRouter
316
- | ((t: BunderstackTRPC<TSchema, ValidatedEnv<TEnv>>) => AnyRouter)
238
+ TCustomApiRouter
239
+ > & {
317
240
  jobs?:
318
241
  | JobsDefs
319
242
  | ((j: BunderstackJobsBuilder<TSchema, ValidatedEnv<TEnv>>) => JobsDefs)
@@ -324,8 +247,8 @@ export async function createBunderstack<
324
247
  TAccess,
325
248
  BucketNamesOf<TStorage>,
326
249
  TEnv,
327
- AnyRouter | undefined,
328
- JobsDefs | undefined
250
+ JobsDefs | undefined,
251
+ TCustomApiRouter
329
252
  >
330
253
  > {
331
254
  const dialect = detectDialect(options.schema)
@@ -336,7 +259,7 @@ export async function createBunderstack<
336
259
  : undefined
337
260
  if (jobsDefs) validateJobsDefs(jobsDefs)
338
261
  // Env is validated FIRST: the app refuses to boot on missing/invalid vars,
339
- // and everything downstream (config, email, trpc ctx) consumes the result.
262
+ // and everything downstream consumes the result.
340
263
  const env = validateEnv(options.env, {
341
264
  emailProvider: emailProviderTag(options.email),
342
265
  defaultDatabaseUrl:
@@ -388,81 +311,51 @@ export async function createBunderstack<
388
311
  typeof config.realtime === 'object'
389
312
  ? config.realtime.bufferSize
390
313
  : undefined
314
+ const realtimeResumeSeconds =
315
+ typeof config.realtime === 'object'
316
+ ? config.realtime.resumeSeconds
317
+ : undefined
391
318
  const configuredRedisUrl = config.realtime
392
319
  ? resolveRealtimeRedisUrl(config.realtime, env)
393
320
  : undefined
394
- const configuredRealtimeTransport: RealtimeTransport = !config.realtime
395
- ? 'disabled'
396
- : configuredRedisUrl
397
- ? 'redis'
398
- : 'memory'
399
321
  const redisUrl = introspect ? undefined : configuredRedisUrl
400
- const broker = config.realtime
322
+ const publisher = config.realtime
401
323
  ? redisUrl
402
- ? createRedisRealtimeBroker({
403
- access: resolvedAccess,
404
- channel: process.env.BUNDERSTACK_REALTIME_CHANNEL || undefined,
405
- redis: () => {
406
- // Redis pub/sub requires a dedicated connection (subscribe puts the client into
407
- // a restricted state). We use one client for commands and a second for subscribe.
408
- const cmdClient = new Bun.RedisClient(redisUrl)
409
- const subClient = new Bun.RedisClient(redisUrl)
410
- return {
411
- incr: (key: string) => cmdClient.incr(key),
412
- publish: (channel: string, message: string) =>
413
- cmdClient.publish(channel, message),
414
- subscribe: (channel: string, listener: (msg: string) => void) =>
415
- subClient.subscribe(channel, listener),
416
- lpush: (key: string, value: string) =>
417
- cmdClient.lpush(key, value),
418
- ltrim: (key: string, start: number, stop: number) =>
419
- cmdClient.ltrim(key, start, stop),
420
- lrange: (key: string, start: number, stop: number) =>
421
- cmdClient.lrange(key, start, stop),
422
- close: () => {
423
- cmdClient.close()
424
- subClient.close()
425
- },
426
- }
427
- },
428
- bufferSize: realtimeBufferSize,
429
- })
430
- : createRealtimeBroker({
431
- access: resolvedAccess,
432
- bufferSize: realtimeBufferSize,
324
+ ? (() => {
325
+ const redis = new Bun.RedisClient(redisUrl)
326
+ const subscriber = redis.duplicate()
327
+ lifecycle.add(async () => {
328
+ redis.close()
329
+ ;(await subscriber).close()
330
+ })
331
+ return createRedisRealtimePublisher(redis, subscriber, {
332
+ prefix:
333
+ process.env.BUNDERSTACK_REALTIME_PREFIX ?? 'bunderstack:',
334
+ maxBufferedEvents: realtimeBufferSize,
335
+ resumeSeconds: realtimeResumeSeconds,
336
+ })
337
+ })()
338
+ : createMemoryRealtimePublisher({
339
+ maxBufferedEvents: realtimeBufferSize,
340
+ resumeSeconds: realtimeResumeSeconds,
433
341
  })
434
342
  : undefined
435
- const runtimeRealtimeTransport: RealtimeTransport = !broker
343
+ const runtimeRealtimeTransport: RealtimeTransport = !publisher
436
344
  ? 'disabled'
437
345
  : redisUrl
438
346
  ? 'redis'
439
347
  : 'memory'
440
348
  const realtime = createRealtimeFacade<TSchema>(
441
- broker,
349
+ publisher,
442
350
  runtimeRealtimeTransport,
443
351
  )
444
- const crudRouter = buildCrudRouter(options.schema, userDb, {
445
- auth: authResolver,
446
- access: resolvedAccess,
447
- idempotency: options.idempotency,
448
- realtime,
449
- })
450
- const realtimeRouter = broker
451
- ? buildRealtimeRouter(broker, {
452
- auth: authResolver,
453
- keepaliveMs:
454
- typeof config.realtime === 'object'
455
- ? config.realtime.keepaliveMs
456
- : undefined,
457
- })
458
- : undefined
459
352
  const registry = createBucketStorages(config.storage)
460
- if (broker) lifecycle.add(() => broker.close())
461
- const storageRouter = buildBucketStorageRouter({
353
+ const storageOperations = createStorageOperations({
462
354
  registry,
463
355
  db,
464
- auth: authResolver,
465
356
  })
357
+ const storageApiRouter = buildStorageApiRouter(registry, storageOperations)
358
+ const realtimeApiRouter = buildRealtimeApiRouter(publisher, resolvedAccess)
466
359
  const storage: StorageFacade = {
467
360
  async delete(fileId) {
468
361
  const bucketName = fileId.split('/')[0] ?? ''
@@ -546,7 +439,9 @@ export async function createBunderstack<
546
439
  return result
547
440
  },
548
441
  tick(now?: number) {
549
- return jobRunner ? jobRunner.tick(now) : Promise.resolve({ claimed: 0, ran: 0, failed: 0 })
442
+ return jobRunner
443
+ ? jobRunner.tick(now)
444
+ : Promise.resolve({ claimed: 0, ran: 0, failed: 0 })
550
445
  },
551
446
  }
552
447
  if (jobRunner) jobRunner.setJobsFacade(jobs)
@@ -605,58 +500,144 @@ export async function createBunderstack<
605
500
  await lifecycle.close()
606
501
  }
607
502
  }
608
- const trpcRouter: AnyRouter | undefined =
609
- typeof options.trpc === 'function'
610
- ? options.trpc(createTRPC<TSchema, ValidatedEnv<TEnv>>())
611
- : options.trpc
612
- const trpcHandler = trpcRouter
613
- ? (req: Request) =>
614
- fetchRequestHandler({
615
- endpoint: '/api/trpc',
616
- req,
617
- router: trpcRouter,
618
- createContext: async () => ({
619
- db: userDb,
620
- user: await resolveAccessUser(authResolver, req.headers),
621
- env,
622
- email,
623
- jobs,
624
- realtime,
625
- storage,
626
- req,
627
- }),
628
- })
503
+ const crudApiRouter = buildCrudApiRouter(options.schema, userDb, {
504
+ access: resolvedAccess,
505
+ idempotency: options.idempotency,
506
+ realtime,
507
+ })
508
+
509
+ const customApiRouter = options.api
510
+ ? options.api(createApiBuilder<TSchema, ValidatedEnv<TEnv>>())
629
511
  : undefined
630
- const customRouter = options.routes
631
- ? (() => {
632
- const routeCtx = createRouteContext({
633
- db: userDb,
634
- env,
635
- storage,
636
- email,
637
- jobs,
638
- realtime,
639
- auth,
640
- authResolver,
641
- })
642
- const built = (
643
- options.routes as (ctx: unknown) => import('hono').Hono
644
- )(routeCtx)
645
- const enabledTables = Object.values(options.schema)
646
- .filter((table) => isTable(table))
647
- .map((table) => getTableName(table))
648
- .filter((name) => tableEntryForName(resolvedAccess, name)?.enabled)
649
- validateCustomRoutes(built.routes, enabledTables)
650
- return built
651
- })()
512
+
513
+ const nativeRouter = buildApiRouter({
514
+ crud: crudApiRouter as Record<string, unknown>,
515
+ storage: storageApiRouter as Record<string, unknown>,
516
+ realtime: realtimeApiRouter as Record<string, unknown> | undefined,
517
+ custom: customApiRouter as Record<string, unknown> | undefined,
518
+ }) as any
519
+
520
+ const authOpenAPISpecRaw =
521
+ options.openapi &&
522
+ auth.api &&
523
+ 'generateOpenAPISchema' in auth.api &&
524
+ typeof auth.api.generateOpenAPISchema === 'function'
525
+ ? await auth.api.generateOpenAPISchema()
526
+ : undefined
527
+
528
+ const authOpenAPISpec = authOpenAPISpecRaw
529
+ ? normalizeForeignOpenAPISpec(authOpenAPISpecRaw, {
530
+ prefix: '/api/auth',
531
+ source: 'auth',
532
+ })
533
+ : undefined
534
+
535
+ await buildApiRegistry({
536
+ nativeRouter,
537
+ foreignSpecs: authOpenAPISpec ? [authOpenAPISpec] : [],
538
+ reservedCoreHandles: new Set([
539
+ 'health',
540
+ ...(publisher ? ['realtime.changes'] : []),
541
+ ...[...registry.keys()].flatMap((name) =>
542
+ ['prepareUpload', 'upload', 'confirmUpload', 'download', 'delete'].map(
543
+ (operation) => `files.${name}.${operation}`,
544
+ ),
545
+ ),
546
+ ]),
547
+ })
548
+
549
+ const combinedOpenAPISpec = options.openapi
550
+ ? mergeOpenAPISpecs({
551
+ nativeSpec: await new OpenAPIGenerator({
552
+ converters: [
553
+ new ValibotToJsonSchemaConverter(),
554
+ {
555
+ condition: (schema: any) =>
556
+ Boolean(
557
+ schema?.['~standard'] &&
558
+ !schema['~standard'].jsonSchema,
559
+ ),
560
+ convert: (schema: any) => {
561
+ const vendor = schema?.['~standard']?.vendor ?? 'unknown'
562
+ throw new OpenAPIGeneratorError(
563
+ `No JSON Schema converter is configured for Standard Schema vendor "${vendor}"`,
564
+ )
565
+ },
566
+ },
567
+ ],
568
+ }).generate(nativeRouter),
569
+ authSpec: authOpenAPISpec,
570
+ })
652
571
  : undefined
653
- const { handler, router } = buildHandler({
654
- customRouter,
655
- crudRouter,
572
+
573
+ const openapiHandler = new OpenAPIHandler(nativeRouter, {
574
+ errorStatusMap: BUNDERSTACK_ERROR_STATUS_MAP,
575
+ customErrorResponseBodyEncoder: (error: any) => ({
576
+ error: error.message,
577
+ code: error.data?.code ?? error.code,
578
+ ...(error.data?.details !== undefined
579
+ ? { details: error.data.details }
580
+ : {}),
581
+ }),
582
+ fetchInterceptors: [
583
+ async (options) => {
584
+ const res = await options.next()
585
+ if (res.matched && options.context.resHeaders) {
586
+ options.context.resHeaders.forEach((v: string, k: string) =>
587
+ res.response.headers.set(k, v),
588
+ )
589
+ }
590
+ return res
591
+ },
592
+ ],
593
+ })
594
+ const rpcHandler = new RPCHandler(nativeRouter)
595
+
596
+ const apiHandler = async (req: Request): Promise<Response | null> => {
597
+ const urlString = typeof req === 'string' ? (req as string) : req.url
598
+ if (!urlString) return null
599
+ const url = new URL(urlString, 'http://localhost')
600
+ if (
601
+ combinedOpenAPISpec &&
602
+ url.pathname === '/api/openapi.json' &&
603
+ req.method === 'GET'
604
+ ) {
605
+ return new Response(JSON.stringify(combinedOpenAPISpec), {
606
+ headers: { 'Content-Type': 'application/json' },
607
+ })
608
+ }
609
+
610
+ const apiCtx = createApiContext(
611
+ {
612
+ db: userDb,
613
+ env,
614
+ storage,
615
+ email,
616
+ jobs,
617
+ realtime,
618
+ auth,
619
+ authResolver,
620
+ },
621
+ req,
622
+ )
623
+
624
+ if (url.pathname.startsWith('/api/rpc')) {
625
+ const res = await rpcHandler.handle(req, {
626
+ prefix: '/api/rpc',
627
+ context: apiCtx,
628
+ })
629
+ if (res.matched) return res.response
630
+ }
631
+
632
+ const openapiRes = await openapiHandler.handle(req, { context: apiCtx })
633
+ if (openapiRes.matched) return openapiRes.response
634
+
635
+ return null
636
+ }
637
+
638
+ const handler = buildHandler({
656
639
  authHandler: (req) => auth.handler(req),
657
- storageRouter,
658
- realtimeRouter,
659
- trpcHandler,
640
+ apiHandler,
660
641
  rateLimit: options.rateLimit,
661
642
  })
662
643
 
@@ -678,15 +659,14 @@ export async function createBunderstack<
678
659
  TAccess,
679
660
  BucketNamesOf<TStorage>,
680
661
  TEnv,
681
- AnyRouter | undefined,
682
- JobsDefs | undefined
662
+ JobsDefs | undefined,
663
+ TCustomApiRouter
683
664
  > = {
684
665
  handler,
685
666
  // Internal tables live on the runtime db but stay out of the public type.
686
667
  db: userDb,
687
668
  auth,
688
669
  storage,
689
- router,
690
670
  env,
691
671
  email,
692
672
  realtime,
@@ -702,7 +682,6 @@ export async function createBunderstack<
702
682
  return lifecycle.status
703
683
  },
704
684
  signal: lifecycle.signal,
705
- trpcRouter,
706
685
  manifest: buildManifest({
707
686
  schema: options.schema,
708
687
  dialect,
@@ -743,6 +722,8 @@ export async function createBunderstack<
743
722
  }
744
723
 
745
724
  export { MAX_LIST_LIMIT } from './list-query'
725
+ export { BunderstackError } from './errors'
726
+ export type { BunderstackErrorCode } from './errors'
746
727
  export { resolveConfig } from './config'
747
728
  export type {
748
729
  BetterAuthConfig,
@@ -760,8 +741,6 @@ export type {
760
741
  EmailConfigInput,
761
742
  EmailFacade,
762
743
  } from './email'
763
- export { createTRPC } from './trpc'
764
- export type { BunderstackTRPC, TRPCContext } from './trpc'
765
744
  export { createJobsBuilder } from './jobs/index'
766
745
  export type {
767
746
  BunderstackJobContext,
@@ -817,7 +796,7 @@ export type {
817
796
  export type { TransformSpec } from './storage/thumbnails'
818
797
  export { mockAuthSession } from './testing'
819
798
 
820
- export type { RealtimeAction } from './realtime/index'
799
+ export type { RealtimeAction } from './realtime/publisher'
821
800
  export { createRealtimeFacade } from './realtime/facade'
822
801
  export type {
823
802
  RealtimeFacade,
@@ -825,9 +804,19 @@ export type {
825
804
  SchemaTable,
826
805
  } from './realtime/facade'
827
806
 
807
+ export { createApiBuilder } from './api/builder'
808
+ export type { BunderstackApiBuilder, ApiFactory } from './api/builder'
828
809
  export type {
829
- BunderstackRouteContext,
830
- RouteContext,
831
- RoutesBuilder,
832
- } from './routes'
833
-
810
+ CrudApiRouterFor,
811
+ ExposedApiTables,
812
+ MergeApiRouterTypes,
813
+ UnifiedApiRouter,
814
+ } from './api/types'
815
+ export type { TableCrudProcedures } from './api/crud-router'
816
+ export {
817
+ buildApiRegistry,
818
+ mergeApiRoutersStrict,
819
+ normalizeApiPath,
820
+ normalizeForeignOpenAPISpec,
821
+ } from './api/registry'
822
+ export { mergeOpenAPISpecs } from './api/openapi'