bunderstack 0.15.2 → 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.
Files changed (47) hide show
  1. package/README.md +25 -138
  2. package/package.json +22 -14
  3. package/src/access.ts +24 -1
  4. package/src/api/api-types.types.ts +106 -0
  5. package/src/api/builder.ts +52 -0
  6. package/src/api/context.ts +83 -0
  7. package/src/api/crud-router.ts +321 -0
  8. package/src/api/openapi.ts +184 -0
  9. package/src/api/realtime-router.ts +75 -0
  10. package/src/api/registry.ts +338 -0
  11. package/src/api/router.ts +34 -0
  12. package/src/api/storage-router.ts +224 -0
  13. package/src/api/types.ts +84 -0
  14. package/src/auth.ts +5 -0
  15. package/src/blueprint.ts +88 -105
  16. package/src/config.ts +73 -77
  17. package/src/cron.ts +2 -1
  18. package/src/crud-operations.ts +488 -0
  19. package/src/dialect.ts +1 -1
  20. package/src/env.ts +28 -21
  21. package/src/errors.ts +90 -23
  22. package/src/handler.ts +16 -44
  23. package/src/index.ts +283 -294
  24. package/src/internal-tables-pg.ts +1 -17
  25. package/src/internal-tables.ts +0 -31
  26. package/src/jobs/define.ts +75 -21
  27. package/src/jobs/index.ts +3 -9
  28. package/src/jobs/queue.ts +14 -6
  29. package/src/jobs/slots.ts +52 -0
  30. package/src/jobs/worker.ts +142 -42
  31. package/src/manifest.ts +84 -93
  32. package/src/realtime/facade.ts +16 -13
  33. package/src/realtime/filter.ts +77 -0
  34. package/src/realtime/heartbeat.ts +80 -0
  35. package/src/realtime/publisher.ts +46 -0
  36. package/src/standard-schema.ts +59 -0
  37. package/src/storage/index.ts +8 -0
  38. package/src/storage/operations.ts +398 -0
  39. package/src/crud.ts +0 -408
  40. package/src/jobs/cron-auth.ts +0 -28
  41. package/src/jobs/cron-router.ts +0 -135
  42. package/src/jobs/cron-runner.ts +0 -224
  43. package/src/jobs/local-cron.ts +0 -78
  44. package/src/realtime/index.ts +0 -250
  45. package/src/realtime/redis.ts +0 -228
  46. package/src/storage/router.ts +0 -531
  47. package/src/trpc.ts +0 -57
package/src/index.ts CHANGED
@@ -1,25 +1,45 @@
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'
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'
6
8
 
7
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'
8
17
  import type { DbFor } from './db'
9
18
  import type {
10
19
  BunderstackJobsBuilder,
11
20
  EnqueueOptions,
12
21
  JobsDefs,
13
22
  JobsFacade,
14
- LocalCronScheduler,
15
- LocalCronSchedulerOptions,
16
23
  StartWorkerOptions,
17
24
  WorkerHandle,
18
25
  } from './jobs/index'
19
26
  import type { StorageConfigInput } from './storage/buckets'
20
27
  import type { StorageAdapter } from './storage/index'
21
28
 
22
- import { resolveAccessUser, validateAndResolveAccess } from './access'
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'
37
+ import {
38
+ buildApiRegistry,
39
+ mergeApiRoutersStrict,
40
+ normalizeApiPath,
41
+ normalizeForeignOpenAPISpec,
42
+ } from './api/registry'
23
43
  import {
24
44
  createAuth,
25
45
  toAuthSessionResolver,
@@ -27,20 +47,17 @@ import {
27
47
  } from './auth'
28
48
  import { resolveConfig, type BunderstackConfig } from './config'
29
49
  import { resolveRealtimeRedisUrl } from './config'
30
- import { buildCrudRouter } from './crud'
31
50
  import { createDb } from './db'
32
51
  import { detectDialect } from './dialect'
33
52
  import { createEmail, emailProviderTag, type EmailFacade } from './email'
34
53
  import { validateEnv, type EnvConfigInput, type ValidatedEnv } from './env'
54
+ import { BUNDERSTACK_ERROR_STATUS_MAP } from './errors'
35
55
  import { buildHandler } from './handler'
36
56
  import { withInternalTables } from './internal-tables'
37
57
  import {
38
58
  createJobsBuilder,
39
59
  createJobRunner,
40
- buildCronRouter,
41
60
  enqueueJob,
42
- runCronSlot,
43
- startLocalCronScheduler,
44
61
  startJobWorker,
45
62
  validateJobsDefs,
46
63
  } from './jobs/index'
@@ -55,16 +72,17 @@ import {
55
72
  type RealtimeFacade,
56
73
  type RealtimeTransport,
57
74
  } from './realtime/facade'
58
- import { createRealtimeBroker, buildRealtimeRouter } from './realtime/index'
59
- import { createRedisRealtimeBroker } from './realtime/redis'
75
+ import {
76
+ createMemoryRealtimePublisher,
77
+ createRedisRealtimePublisher,
78
+ } from './realtime/publisher'
60
79
  import { deleteFileWithDerivatives } from './storage/delete'
61
80
  import { deleteFileMetaRow, insertReadyFile } from './storage/file-meta'
62
81
  import { createBucketStorages } from './storage/registry'
63
- import { buildBucketStorageRouter } from './storage/router'
82
+ import { createStorageOperations } from './storage/operations'
64
83
  import { sweepOrphans } from './storage/sweep'
65
- import { createTRPC, type BunderstackTRPC } from './trpc'
66
84
 
67
- type AuthInstance = ReturnType<typeof createAuth>
85
+ export type AuthInstance = ReturnType<typeof createAuth>
68
86
 
69
87
  function waitForWorkerShutdown(
70
88
  signal: AbortSignal,
@@ -138,11 +156,6 @@ export type AppRunWorkerOptions = AppStartWorkerOptions & {
138
156
  */
139
157
  allowProcessLocalRealtime?: boolean
140
158
  }
141
- export type AppStartCronSchedulerOptions = Pick<
142
- LocalCronSchedulerOptions,
143
- 'onError'
144
- >
145
-
146
159
  /** Bucket names declared in a storage config; `string` when unknowable. */
147
160
  export type BucketNamesOf<TStorage> = TStorage extends {
148
161
  buckets: infer B extends Record<string, unknown>
@@ -155,16 +168,13 @@ export type BunderstackApp<
155
168
  TAccess extends Record<string, TableAccessInput> | undefined = undefined,
156
169
  TBuckets extends string = string,
157
170
  TEnv extends EnvConfigInput | undefined = undefined,
158
- TRouter = undefined,
159
171
  TJobsDefs extends JobsDefs | undefined = undefined,
172
+ TCustomApiRouter extends AnyORPCRouter | undefined = undefined,
160
173
  > = {
161
174
  handler: (req: Request) => Promise<Response>
162
175
  db: DbFor<TSchema>
163
176
  auth: AuthInstance
164
177
  storage: StorageFacade
165
- router: HonoType
166
- /** Raw tRPC router when the config declared one — escape hatch. */
167
- trpcRouter?: AnyRouter
168
178
  /** Validated env: bunderstack's base vars plus the config's `env` extension. */
169
179
  env: ValidatedEnv<TEnv>
170
180
  /** Email facade; always present — send() throws when email isn't configured. */
@@ -178,11 +188,9 @@ export type BunderstackApp<
178
188
  startWorker(options?: AppStartWorkerOptions): Promise<WorkerHandle>
179
189
  /** Run a queue worker until aborted, then close the application. */
180
190
  runWorker(options?: AppRunWorkerOptions): Promise<void>
181
- /** Start local delivery for declared cron tasks (for development only). */
182
- startCronScheduler(
183
- options?: AppStartCronSchedulerOptions,
184
- ): Promise<LocalCronScheduler>
185
191
  close(): Promise<void>
192
+ /** True when this process is running the background tick loop. */
193
+ readonly backgroundRunning: boolean
186
194
  readonly status: LifecycleStatus
187
195
  readonly signal: AbortSignal
188
196
  /** Deploy-time introspection: what this app needs provisioned. */
@@ -195,129 +203,40 @@ export type BunderstackApp<
195
203
  schema: TSchema
196
204
  access: TAccess
197
205
  buckets: TBuckets
198
- trpc: TRouter
206
+ api: MergeApiRouterTypes<
207
+ UnifiedApiRouter<CrudApiRouterFor<TSchema, TAccess>, TCustomApiRouter>,
208
+ RealtimeApiRouter
209
+ >
199
210
  }
200
211
  }
201
212
 
202
- // Overloads: the builder-callback form and the prebuilt-router/none form are
203
- // separate signatures so the callback's `t` parameter gets contextual typing
204
- // and the router type lands on `$inferClient` without conditional-type
205
- // inference (which breaks under contextual return types). `jobs` needs the
206
- // same split against BOTH trpc forms — a union parameter type (`TJobsDefs |
207
- // (callback => TJobsDefs)`) defeats inference (TS widens TJobsDefs to its
208
- // constraint when a function literal could match either union arm) — hence
209
- // four overloads covering the trpc × jobs option cross product.
210
213
  export function createBunderstack<
211
214
  TSchema extends Record<string, unknown>,
212
- const TAccess extends Record<string, TableAccessInput> | undefined =
213
- undefined,
215
+ const TAccess extends Record<string, TableAccessInput> | undefined = undefined,
214
216
  const TStorage extends StorageConfigInput | undefined = undefined,
215
217
  const TEnv extends EnvConfigInput | undefined = undefined,
216
- TRouter extends AnyRouter = AnyRouter,
217
218
  const TJobsDefs extends JobsDefs | undefined = undefined,
219
+ TCustomApiRouter extends AnyORPCRouter | undefined = undefined,
218
220
  >(
219
- options: BunderstackConfig<TSchema, TAccess, TStorage, TEnv> & {
220
- /** Builder callback receiving the pre-wired `t` instance. */
221
- trpc: (t: BunderstackTRPC<TSchema, ValidatedEnv<TEnv>>) => TRouter
222
- /** Builder callback receiving the pre-wired `j` instance. */
223
- jobs: (j: BunderstackJobsBuilder<TSchema, ValidatedEnv<TEnv>>) => TJobsDefs
221
+ options: BunderstackConfig<TSchema, TAccess, TStorage, TEnv, TCustomApiRouter> & {
222
+ jobs?: TJobsDefs | ((j: BunderstackJobsBuilder<TSchema, ValidatedEnv<TEnv>>) => TJobsDefs)
224
223
  },
225
- ): Promise<
226
- BunderstackApp<
227
- TSchema,
228
- TAccess,
229
- BucketNamesOf<TStorage>,
230
- TEnv,
231
- TRouter,
232
- TJobsDefs
233
- >
234
- >
235
- export function createBunderstack<
236
- TSchema extends Record<string, unknown>,
237
- const TAccess extends Record<string, TableAccessInput> | undefined =
238
- undefined,
239
- const TStorage extends StorageConfigInput | undefined = undefined,
240
- const TEnv extends EnvConfigInput | undefined = undefined,
241
- TRouter extends AnyRouter = AnyRouter,
242
- const TJobsDefs extends JobsDefs | undefined = undefined,
243
- >(
244
- options: BunderstackConfig<TSchema, TAccess, TStorage, TEnv> & {
245
- /** Builder callback receiving the pre-wired `t` instance. */
246
- trpc: (t: BunderstackTRPC<TSchema, ValidatedEnv<TEnv>>) => TRouter
247
- /** Prebuilt job definitions (escape hatch for multi-file setups). */
248
- jobs?: TJobsDefs
249
- },
250
- ): Promise<
251
- BunderstackApp<
252
- TSchema,
253
- TAccess,
254
- BucketNamesOf<TStorage>,
255
- TEnv,
256
- TRouter,
257
- TJobsDefs
258
- >
259
- >
260
- export function createBunderstack<
261
- TSchema extends Record<string, unknown>,
262
- const TAccess extends Record<string, TableAccessInput> | undefined =
263
- undefined,
264
- const TStorage extends StorageConfigInput | undefined = undefined,
265
- const TEnv extends EnvConfigInput | undefined = undefined,
266
- TRouter extends AnyRouter | undefined = undefined,
267
- const TJobsDefs extends JobsDefs | undefined = undefined,
268
- >(
269
- options: BunderstackConfig<TSchema, TAccess, TStorage, TEnv> & {
270
- /** Prebuilt tRPC router (escape hatch for multi-file setups). */
271
- trpc?: TRouter
272
- /** Builder callback receiving the pre-wired `j` instance. */
273
- jobs: (j: BunderstackJobsBuilder<TSchema, ValidatedEnv<TEnv>>) => TJobsDefs
274
- },
275
- ): Promise<
276
- BunderstackApp<
277
- TSchema,
278
- TAccess,
279
- BucketNamesOf<TStorage>,
280
- TEnv,
281
- TRouter,
282
- TJobsDefs
283
- >
284
- >
285
- export function createBunderstack<
224
+ ): Promise<BunderstackApp<TSchema, TAccess, BucketNamesOf<TStorage>, TEnv, TJobsDefs, TCustomApiRouter>>
225
+ export async function createBunderstack<
286
226
  TSchema extends Record<string, unknown>,
287
227
  const TAccess extends Record<string, TableAccessInput> | undefined =
288
228
  undefined,
289
229
  const TStorage extends StorageConfigInput | undefined = undefined,
290
230
  const TEnv extends EnvConfigInput | undefined = undefined,
291
- TRouter extends AnyRouter | undefined = undefined,
292
- const TJobsDefs extends JobsDefs | undefined = undefined,
231
+ TCustomApiRouter extends AnyORPCRouter | undefined = undefined,
293
232
  >(
294
- options: BunderstackConfig<TSchema, TAccess, TStorage, TEnv> & {
295
- /** Prebuilt tRPC router (escape hatch for multi-file setups). */
296
- trpc?: TRouter
297
- /** Prebuilt job definitions (escape hatch for multi-file setups). */
298
- jobs?: TJobsDefs
299
- },
300
- ): Promise<
301
- BunderstackApp<
233
+ options: BunderstackConfig<
302
234
  TSchema,
303
235
  TAccess,
304
- BucketNamesOf<TStorage>,
236
+ TStorage,
305
237
  TEnv,
306
- TRouter,
307
- TJobsDefs
308
- >
309
- >
310
- export async function createBunderstack<
311
- TSchema extends Record<string, unknown>,
312
- const TAccess extends Record<string, TableAccessInput> | undefined =
313
- undefined,
314
- const TStorage extends StorageConfigInput | undefined = undefined,
315
- const TEnv extends EnvConfigInput | undefined = undefined,
316
- >(
317
- options: BunderstackConfig<TSchema, TAccess, TStorage, TEnv> & {
318
- trpc?:
319
- | AnyRouter
320
- | ((t: BunderstackTRPC<TSchema, ValidatedEnv<TEnv>>) => AnyRouter)
238
+ TCustomApiRouter
239
+ > & {
321
240
  jobs?:
322
241
  | JobsDefs
323
242
  | ((j: BunderstackJobsBuilder<TSchema, ValidatedEnv<TEnv>>) => JobsDefs)
@@ -328,8 +247,8 @@ export async function createBunderstack<
328
247
  TAccess,
329
248
  BucketNamesOf<TStorage>,
330
249
  TEnv,
331
- AnyRouter | undefined,
332
- JobsDefs | undefined
250
+ JobsDefs | undefined,
251
+ TCustomApiRouter
333
252
  >
334
253
  > {
335
254
  const dialect = detectDialect(options.schema)
@@ -340,16 +259,14 @@ export async function createBunderstack<
340
259
  : undefined
341
260
  if (jobsDefs) validateJobsDefs(jobsDefs)
342
261
  // Env is validated FIRST: the app refuses to boot on missing/invalid vars,
343
- // and everything downstream (config, email, trpc ctx) consumes the result.
262
+ // and everything downstream consumes the result.
344
263
  const env = validateEnv(options.env, {
345
264
  emailProvider: emailProviderTag(options.email),
346
265
  defaultDatabaseUrl:
347
266
  dialect === 'pg' ? 'file:./data.pglite' : 'file:./data.db',
348
- // Storage orphan cleanup is also a signed platform schedule, so every
349
- // production app has scheduled delivery even without user-defined cron.
350
- cronConfigured: true,
267
+ source: options.processEnv,
351
268
  })
352
- const config = resolveConfig(options, env)
269
+ const config = resolveConfig(options, env, options.processEnv)
353
270
  // Adapters use Drizzle mocks during deployment introspection, so the database
354
271
  // and Redis below never touch external services.
355
272
  const introspect = process.env.BUNDERSTACK_INTROSPECT === '1'
@@ -394,81 +311,51 @@ export async function createBunderstack<
394
311
  typeof config.realtime === 'object'
395
312
  ? config.realtime.bufferSize
396
313
  : undefined
314
+ const realtimeResumeSeconds =
315
+ typeof config.realtime === 'object'
316
+ ? config.realtime.resumeSeconds
317
+ : undefined
397
318
  const configuredRedisUrl = config.realtime
398
319
  ? resolveRealtimeRedisUrl(config.realtime, env)
399
320
  : undefined
400
- const configuredRealtimeTransport: RealtimeTransport = !config.realtime
401
- ? 'disabled'
402
- : configuredRedisUrl
403
- ? 'redis'
404
- : 'memory'
405
321
  const redisUrl = introspect ? undefined : configuredRedisUrl
406
- const broker = config.realtime
322
+ const publisher = config.realtime
407
323
  ? redisUrl
408
- ? createRedisRealtimeBroker({
409
- access: resolvedAccess,
410
- channel: process.env.BUNDERSTACK_REALTIME_CHANNEL || undefined,
411
- redis: () => {
412
- // Redis pub/sub requires a dedicated connection (subscribe puts the client into
413
- // a restricted state). We use one client for commands and a second for subscribe.
414
- const cmdClient = new Bun.RedisClient(redisUrl)
415
- const subClient = new Bun.RedisClient(redisUrl)
416
- return {
417
- incr: (key: string) => cmdClient.incr(key),
418
- publish: (channel: string, message: string) =>
419
- cmdClient.publish(channel, message),
420
- subscribe: (channel: string, listener: (msg: string) => void) =>
421
- subClient.subscribe(channel, listener),
422
- lpush: (key: string, value: string) =>
423
- cmdClient.lpush(key, value),
424
- ltrim: (key: string, start: number, stop: number) =>
425
- cmdClient.ltrim(key, start, stop),
426
- lrange: (key: string, start: number, stop: number) =>
427
- cmdClient.lrange(key, start, stop),
428
- close: () => {
429
- cmdClient.close()
430
- subClient.close()
431
- },
432
- }
433
- },
434
- bufferSize: realtimeBufferSize,
435
- })
436
- : createRealtimeBroker({
437
- access: resolvedAccess,
438
- 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,
439
341
  })
440
342
  : undefined
441
- const runtimeRealtimeTransport: RealtimeTransport = !broker
343
+ const runtimeRealtimeTransport: RealtimeTransport = !publisher
442
344
  ? 'disabled'
443
345
  : redisUrl
444
346
  ? 'redis'
445
347
  : 'memory'
446
348
  const realtime = createRealtimeFacade<TSchema>(
447
- broker,
349
+ publisher,
448
350
  runtimeRealtimeTransport,
449
351
  )
450
- const crudRouter = buildCrudRouter(options.schema, userDb, {
451
- auth: authResolver,
452
- access: resolvedAccess,
453
- idempotency: options.idempotency,
454
- realtime,
455
- })
456
- const realtimeRouter = broker
457
- ? buildRealtimeRouter(broker, {
458
- auth: authResolver,
459
- keepaliveMs:
460
- typeof config.realtime === 'object'
461
- ? config.realtime.keepaliveMs
462
- : undefined,
463
- })
464
- : undefined
465
352
  const registry = createBucketStorages(config.storage)
466
- if (broker) lifecycle.add(() => broker.close())
467
- const storageRouter = buildBucketStorageRouter({
353
+ const storageOperations = createStorageOperations({
468
354
  registry,
469
355
  db,
470
- auth: authResolver,
471
356
  })
357
+ const storageApiRouter = buildStorageApiRouter(registry, storageOperations)
358
+ const realtimeApiRouter = buildRealtimeApiRouter(publisher, resolvedAccess)
472
359
  const storage: StorageFacade = {
473
360
  async delete(fileId) {
474
361
  const bucketName = fileId.split('/')[0] ?? ''
@@ -518,25 +405,43 @@ export async function createBunderstack<
518
405
  })
519
406
  },
520
407
  }
521
- const jobRunner = jobsDefs
408
+ const storageConfigured = Boolean(options.storage)
409
+ // The storage sweep used to be a hardcoded maintenance route. It is an
410
+ // ordinary cron now, so it inherits retries, timeout and onFailed.
411
+ const resolvedDefs: JobsDefs | undefined = storageConfigured
412
+ ? {
413
+ ...(jobsDefs ?? {}),
414
+ 'bunderstack:storage-sweep': {
415
+ kind: 'cron',
416
+ schedule: '0 4 * * *',
417
+ handler: async () => {
418
+ await storage.sweep()
419
+ },
420
+ },
421
+ }
422
+ : jobsDefs
423
+
424
+ const jobRunner = resolvedDefs
522
425
  ? createJobRunner({
523
426
  db,
524
- defs: jobsDefs,
427
+ defs: resolvedDefs,
525
428
  ctx: { db: userDb, env, email, storage, realtime },
526
429
  })
527
430
  : undefined
528
431
  const jobs = {
529
432
  async enqueue(name: string, input?: unknown, opts?: EnqueueOptions) {
530
- if (!jobsDefs) {
433
+ if (!resolvedDefs) {
531
434
  throw new Error(
532
435
  '[bunderstack] no jobs configured — add a `jobs` key to createBunderstack',
533
436
  )
534
437
  }
535
- const result = await enqueueJob(db, jobsDefs, name, input, opts)
438
+ const result = await enqueueJob(db, resolvedDefs, name, input, opts)
536
439
  return result
537
440
  },
538
441
  tick(now?: number) {
539
- return jobRunner ? jobRunner.tick(now) : Promise.resolve()
442
+ return jobRunner
443
+ ? jobRunner.tick(now)
444
+ : Promise.resolve({ claimed: 0, ran: 0, failed: 0 })
540
445
  },
541
446
  }
542
447
  if (jobRunner) jobRunner.setJobsFacade(jobs)
@@ -558,54 +463,16 @@ export async function createBunderstack<
558
463
  const handle = startJobWorker({
559
464
  ...options,
560
465
  signal,
561
- tick: (now) => jobRunner.tick(now),
466
+ // The runtime loop only cares that a tick completed; TickResult is for
467
+ // callers that invoke tick() directly.
468
+ tick: async (now) => {
469
+ await jobRunner.tick(now)
470
+ },
562
471
  })
563
472
  const unregister = lifecycle.add(() => handle.close())
564
473
  void handle.closed.finally(unregister)
565
474
  return handle
566
475
  }
567
- const startCronScheduler = async (
568
- options: AppStartCronSchedulerOptions = {},
569
- ): Promise<LocalCronScheduler> => {
570
- if (introspect) {
571
- return { tick: async () => {}, close: async () => {} }
572
- }
573
- const cron = Object.entries(jobsDefs ?? {}).flatMap(
574
- ([name, definition]) =>
575
- definition.kind === 'cron'
576
- ? [{ name, schedule: definition.schedule }]
577
- : [],
578
- )
579
- if (cron.length === 0) {
580
- throw new Error('[bunderstack] no cron tasks configured')
581
- }
582
- if (lifecycle.status !== 'ready') {
583
- throw new Error('[bunderstack] application lifecycle is closed')
584
- }
585
- const scheduler = startLocalCronScheduler({
586
- cron,
587
- onError: options.onError,
588
- runSlot: async (name, slot) => {
589
- await runCronSlot({
590
- db,
591
- defs: jobsDefs!,
592
- ctx: { db: userDb, env, email, storage, realtime },
593
- name,
594
- slot,
595
- now: Date.now(),
596
- })
597
- },
598
- })
599
- const unregister = lifecycle.add(() => scheduler.close())
600
- try {
601
- await scheduler.tick()
602
- } catch (error) {
603
- unregister()
604
- await scheduler.close()
605
- throw error
606
- }
607
- return scheduler
608
- }
609
476
  const runWorker = async (
610
477
  options: AppRunWorkerOptions = {},
611
478
  ): Promise<void> => {
@@ -633,61 +500,173 @@ export async function createBunderstack<
633
500
  await lifecycle.close()
634
501
  }
635
502
  }
636
- const trpcRouter: AnyRouter | undefined =
637
- typeof options.trpc === 'function'
638
- ? options.trpc(createTRPC<TSchema, ValidatedEnv<TEnv>>())
639
- : options.trpc
640
- const trpcHandler = trpcRouter
641
- ? (req: Request) =>
642
- fetchRequestHandler({
643
- endpoint: '/api/trpc',
644
- req,
645
- router: trpcRouter,
646
- createContext: async () => ({
647
- db: userDb,
648
- user: await resolveAccessUser(authResolver, req.headers),
649
- env,
650
- email,
651
- jobs,
652
- realtime,
653
- storage,
654
- req,
655
- }),
656
- })
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>>())
657
511
  : undefined
658
- const cronRouter = env.BUNDERSTACK_CRON_SECRET
659
- ? buildCronRouter({
660
- db,
661
- defs: jobsDefs ?? {},
662
- ctx: { db: userDb, env, email, storage, realtime },
663
- secret: env.BUNDERSTACK_CRON_SECRET,
664
- storage,
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',
665
532
  })
666
533
  : undefined
667
- const { handler, router } = buildHandler({
668
- crudRouter,
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
+ })
571
+ : undefined
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({
669
639
  authHandler: (req) => auth.handler(req),
670
- storageRouter,
671
- realtimeRouter,
672
- trpcHandler,
673
- cronRouter,
640
+ apiHandler,
674
641
  rateLimit: options.rateLimit,
675
642
  })
676
643
 
644
+ // Topology is a deployment concern: the role decides whether this process
645
+ // runs background work, so application code never has to.
646
+ const roleWantsWorker =
647
+ env.BUNDERSTACK_ROLE === 'all' || env.BUNDERSTACK_ROLE === 'worker'
648
+ const autoStart =
649
+ options.background?.autoStart ??
650
+ (roleWantsWorker && !introspect && resolvedDefs !== undefined)
651
+ let backgroundRunning = false
652
+ if (autoStart) {
653
+ await startWorker()
654
+ backgroundRunning = true
655
+ }
656
+
677
657
  const app: BunderstackApp<
678
658
  TSchema,
679
659
  TAccess,
680
660
  BucketNamesOf<TStorage>,
681
661
  TEnv,
682
- AnyRouter | undefined,
683
- JobsDefs | undefined
662
+ JobsDefs | undefined,
663
+ TCustomApiRouter
684
664
  > = {
685
665
  handler,
686
666
  // Internal tables live on the runtime db but stay out of the public type.
687
667
  db: userDb,
688
668
  auth,
689
669
  storage,
690
- router,
691
670
  env,
692
671
  email,
693
672
  realtime,
@@ -697,13 +676,12 @@ export async function createBunderstack<
697
676
  jobs: jobs as never,
698
677
  startWorker,
699
678
  runWorker,
700
- startCronScheduler,
701
679
  close: () => lifecycle.close(),
680
+ backgroundRunning,
702
681
  get status() {
703
682
  return lifecycle.status
704
683
  },
705
684
  signal: lifecycle.signal,
706
- trpcRouter,
707
685
  manifest: buildManifest({
708
686
  schema: options.schema,
709
687
  dialect,
@@ -712,7 +690,7 @@ export async function createBunderstack<
712
690
  envConfig: options.env as EnvConfigInput | undefined,
713
691
  emailProvider: emailProviderTag(options.email),
714
692
  realtime: Boolean(config.realtime),
715
- jobs: jobsDefs,
693
+ jobs: resolvedDefs,
716
694
  }),
717
695
  }
718
696
 
@@ -744,6 +722,8 @@ export async function createBunderstack<
744
722
  }
745
723
 
746
724
  export { MAX_LIST_LIMIT } from './list-query'
725
+ export { BunderstackError } from './errors'
726
+ export type { BunderstackErrorCode } from './errors'
747
727
  export { resolveConfig } from './config'
748
728
  export type {
749
729
  BetterAuthConfig,
@@ -761,13 +741,7 @@ export type {
761
741
  EmailConfigInput,
762
742
  EmailFacade,
763
743
  } from './email'
764
- export { createTRPC } from './trpc'
765
- export type { BunderstackTRPC, TRPCContext } from './trpc'
766
- export {
767
- createJobsBuilder,
768
- signScheduleRequest,
769
- verifyScheduleRequest,
770
- } from './jobs/index'
744
+ export { createJobsBuilder } from './jobs/index'
771
745
  export type {
772
746
  BunderstackJobContext,
773
747
  BunderstackJobsBuilder,
@@ -783,8 +757,6 @@ export type {
783
757
  JobsRuntimeFacade,
784
758
  QueueJobDefinition,
785
759
  QueueJobKeys,
786
- LocalCronScheduler,
787
- LocalCronSchedulerOptions,
788
760
  RunWorkerOptions,
789
761
  StartWorkerOptions,
790
762
  WorkerHandle,
@@ -824,10 +796,27 @@ export type {
824
796
  export type { TransformSpec } from './storage/thumbnails'
825
797
  export { mockAuthSession } from './testing'
826
798
 
827
- export type { RealtimeAction } from './realtime/index'
799
+ export type { RealtimeAction } from './realtime/publisher'
828
800
  export { createRealtimeFacade } from './realtime/facade'
829
801
  export type {
830
802
  RealtimeFacade,
831
803
  RealtimeTransport,
832
804
  SchemaTable,
833
805
  } from './realtime/facade'
806
+
807
+ export { createApiBuilder } from './api/builder'
808
+ export type { BunderstackApiBuilder, ApiFactory } from './api/builder'
809
+ export type {
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'