bunderstack 0.7.0 → 0.8.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
@@ -4,25 +4,36 @@ import type { Hono as HonoType } from 'hono'
4
4
 
5
5
  import { fetchRequestHandler } from '@trpc/server/adapters/fetch'
6
6
 
7
- import type { StorageAdapter } from './storage/index'
8
7
  import type { TableAccessInput } from './access'
9
8
  import type { DbFor } from './db'
9
+ import type {
10
+ BunderstackJobsBuilder,
11
+ EnqueueOptions,
12
+ JobsDefs,
13
+ JobsFacade,
14
+ LocalCronScheduler,
15
+ LocalCronSchedulerOptions,
16
+ StartWorkerOptions,
17
+ WorkerHandle,
18
+ } from './jobs/index'
10
19
  import type { StorageConfigInput } from './storage/buckets'
20
+ import type { StorageAdapter } from './storage/index'
11
21
 
12
22
  import { resolveAccessUser, validateAndResolveAccess } from './access'
13
- import { createAuth, toAuthSessionResolver, withEmailAuthDefaults } from './auth'
23
+ import {
24
+ createAuth,
25
+ toAuthSessionResolver,
26
+ withEmailAuthDefaults,
27
+ } from './auth'
14
28
  import { resolveConfig, type BunderstackConfig } from './config'
15
29
  import { resolveRealtimeRedisUrl } from './config'
30
+ import { buildCrudRouter } from './crud'
31
+ import { createDb } from './db'
16
32
  import { detectDialect } from './dialect'
17
33
  import { createEmail, emailProviderTag, type EmailFacade } from './email'
18
34
  import { validateEnv, type EnvConfigInput, type ValidatedEnv } from './env'
19
- import { buildManifest, type BunderstackManifest } from './manifest'
20
- import { createTRPC, type BunderstackTRPC } from './trpc'
21
- import { buildCrudRouter } from './crud'
22
- import { createDb } from './db'
23
35
  import { buildHandler } from './handler'
24
36
  import { withInternalTables } from './internal-tables'
25
- import { Lifecycle, type LifecycleStatus } from './lifecycle'
26
37
  import {
27
38
  createJobsBuilder,
28
39
  createJobRunner,
@@ -33,28 +44,21 @@ import {
33
44
  startJobWorker,
34
45
  validateJobsDefs,
35
46
  } from './jobs/index'
36
- import type {
37
- BunderstackJobsBuilder,
38
- EnqueueOptions,
39
- JobsDefs,
40
- JobsFacade,
41
- LocalCronScheduler,
42
- LocalCronSchedulerOptions,
43
- StartWorkerOptions,
44
- WorkerHandle,
45
- } from './jobs/index'
47
+ import { Lifecycle, type LifecycleStatus } from './lifecycle'
48
+ import { buildManifest, type BunderstackManifest } from './manifest'
46
49
  import {
47
50
  PROVISION_INTERNALS,
48
51
  type WithProvisionInternals,
49
52
  } from './provision-internals'
53
+ import { createRealtimeFacade, type RealtimeFacade } from './realtime/facade'
50
54
  import { createRealtimeBroker, buildRealtimeRouter } from './realtime/index'
51
55
  import { createRedisRealtimeBroker } from './realtime/redis'
52
- import { createRealtimeFacade, type RealtimeFacade } from './realtime/facade'
53
56
  import { deleteFileWithDerivatives } from './storage/delete'
54
57
  import { deleteFileMetaRow } from './storage/file-meta'
55
58
  import { createBucketStorages } from './storage/registry'
56
59
  import { buildBucketStorageRouter } from './storage/router'
57
60
  import { sweepOrphans } from './storage/sweep'
61
+ import { createTRPC, type BunderstackTRPC } from './trpc'
58
62
 
59
63
  type AuthInstance = ReturnType<typeof createAuth>
60
64
 
@@ -115,7 +119,6 @@ export type BucketNamesOf<TStorage> = TStorage extends {
115
119
  ? keyof B & string
116
120
  : string
117
121
 
118
-
119
122
  export type BunderstackApp<
120
123
  TSchema extends Record<string, unknown>,
121
124
  TAccess extends Record<string, TableAccessInput> | undefined = undefined,
@@ -136,7 +139,9 @@ export type BunderstackApp<
136
139
  /** Email facade; always present — send() throws when email isn't configured. */
137
140
  email: EmailFacade
138
141
  /** Job queue facade; always present — enqueue throws when jobs aren't configured. */
139
- jobs: JobsFacade<TJobsDefs extends JobsDefs ? TJobsDefs : Record<never, never>>
142
+ jobs: JobsFacade<
143
+ TJobsDefs extends JobsDefs ? TJobsDefs : Record<never, never>
144
+ >
140
145
  /** Typed custom row publication; enabled=false/no-op when realtime is off. */
141
146
  realtime: RealtimeFacade<TSchema>
142
147
  startWorker(options?: AppStartWorkerOptions): Promise<WorkerHandle>
@@ -186,7 +191,16 @@ export function createBunderstack<
186
191
  /** Builder callback receiving the pre-wired `j` instance. */
187
192
  jobs: (j: BunderstackJobsBuilder<TSchema, ValidatedEnv<TEnv>>) => TJobsDefs
188
193
  },
189
- ): Promise<BunderstackApp<TSchema, TAccess, BucketNamesOf<TStorage>, TEnv, TRouter, TJobsDefs>>
194
+ ): Promise<
195
+ BunderstackApp<
196
+ TSchema,
197
+ TAccess,
198
+ BucketNamesOf<TStorage>,
199
+ TEnv,
200
+ TRouter,
201
+ TJobsDefs
202
+ >
203
+ >
190
204
  export function createBunderstack<
191
205
  TSchema extends Record<string, unknown>,
192
206
  const TAccess extends Record<string, TableAccessInput> | undefined =
@@ -202,7 +216,16 @@ export function createBunderstack<
202
216
  /** Prebuilt job definitions (escape hatch for multi-file setups). */
203
217
  jobs?: TJobsDefs
204
218
  },
205
- ): Promise<BunderstackApp<TSchema, TAccess, BucketNamesOf<TStorage>, TEnv, TRouter, TJobsDefs>>
219
+ ): Promise<
220
+ BunderstackApp<
221
+ TSchema,
222
+ TAccess,
223
+ BucketNamesOf<TStorage>,
224
+ TEnv,
225
+ TRouter,
226
+ TJobsDefs
227
+ >
228
+ >
206
229
  export function createBunderstack<
207
230
  TSchema extends Record<string, unknown>,
208
231
  const TAccess extends Record<string, TableAccessInput> | undefined =
@@ -218,7 +241,16 @@ export function createBunderstack<
218
241
  /** Builder callback receiving the pre-wired `j` instance. */
219
242
  jobs: (j: BunderstackJobsBuilder<TSchema, ValidatedEnv<TEnv>>) => TJobsDefs
220
243
  },
221
- ): Promise<BunderstackApp<TSchema, TAccess, BucketNamesOf<TStorage>, TEnv, TRouter, TJobsDefs>>
244
+ ): Promise<
245
+ BunderstackApp<
246
+ TSchema,
247
+ TAccess,
248
+ BucketNamesOf<TStorage>,
249
+ TEnv,
250
+ TRouter,
251
+ TJobsDefs
252
+ >
253
+ >
222
254
  export function createBunderstack<
223
255
  TSchema extends Record<string, unknown>,
224
256
  const TAccess extends Record<string, TableAccessInput> | undefined =
@@ -234,7 +266,16 @@ export function createBunderstack<
234
266
  /** Prebuilt job definitions (escape hatch for multi-file setups). */
235
267
  jobs?: TJobsDefs
236
268
  },
237
- ): Promise<BunderstackApp<TSchema, TAccess, BucketNamesOf<TStorage>, TEnv, TRouter, TJobsDefs>>
269
+ ): Promise<
270
+ BunderstackApp<
271
+ TSchema,
272
+ TAccess,
273
+ BucketNamesOf<TStorage>,
274
+ TEnv,
275
+ TRouter,
276
+ TJobsDefs
277
+ >
278
+ >
238
279
  export async function createBunderstack<
239
280
  TSchema extends Record<string, unknown>,
240
281
  const TAccess extends Record<string, TableAccessInput> | undefined =
@@ -278,243 +319,246 @@ export async function createBunderstack<
278
319
  cronConfigured: true,
279
320
  })
280
321
  const config = resolveConfig(options, env)
281
- // Introspection mode (BUNDERSTACK_INTROSPECT=1): deployment platforms import
282
- // the app declaration only to read `app.manifest`. The boot must never touch
283
- // the outside world — force an in-memory db (':memory:' is valid for both
284
- // dialects) and skip Redis below. Env validation is already lenient (env.ts).
322
+ // Adapters use Drizzle mocks during deployment introspection, so the database
323
+ // and Redis below never touch external services.
285
324
  const introspect = process.env.BUNDERSTACK_INTROSPECT === '1'
286
- if (introspect) {
287
- config.database.url = ':memory:'
288
- config.database.authToken = undefined
289
- }
290
325
  const email = createEmail(options.email, { env })
291
326
  // Merge bunderstack's internal tables (file-meta, idempotency) into the
292
327
  // schema used for the db client + provisioning. CRUD/access stay on the USER
293
328
  // schema so internal tables never get a CRUD route.
294
329
  const mergedSchema = withInternalTables(options.schema)
295
- const { db, driver } = await createDb(mergedSchema, {
330
+ const lifecycle = new Lifecycle()
331
+ const {
332
+ db,
333
+ driver,
334
+ close: closeDatabase,
335
+ } = await createDb(mergedSchema, {
296
336
  ...config.database,
297
337
  dialect,
338
+ introspect,
298
339
  })
299
- // `db` is typed with the merged schema (user tables + internal tables) so the
300
- // storage/idempotency code can query the internal tables. The public surface
301
- // and CRUD only expose the USER schema. TS can widen the merged-schema db type
302
- // on its own (storage/auth pass `db` directly), but it can't *narrow* a
303
- // generic schema view, so this single intentional cast produces the
304
- // user-facing, per-dialect db type. See `app.db` / crud below.
305
- const userDb = db as unknown as DbFor<TSchema>
306
- const auth = createAuth(
307
- db,
308
- withEmailAuthDefaults(config.auth, email, Boolean(options.email)),
309
- dialect,
310
- )
311
- // Internal routers consume the narrow AuthSessionResolver contract, not the
312
- // raw better-auth instance. app.auth still exposes `auth` unchanged.
313
- const authResolver = toAuthSessionResolver(auth)
314
- const resolvedAccess = validateAndResolveAccess(
315
- options.schema,
316
- options.access,
317
- )
318
- const realtimeBufferSize =
319
- typeof config.realtime === 'object' ? config.realtime.bufferSize : undefined
320
- const redisUrl =
321
- config.realtime && !introspect
322
- ? resolveRealtimeRedisUrl(config.realtime, env)
340
+ if (closeDatabase) lifecycle.add(closeDatabase)
341
+ try {
342
+ // `db` is typed with the merged schema (user tables + internal tables) so the
343
+ // storage/idempotency code can query the internal tables. The public surface
344
+ // and CRUD only expose the USER schema. TS can widen the merged-schema db type
345
+ // on its own (storage/auth pass `db` directly), but it can't *narrow* a
346
+ // generic schema view, so this single intentional cast produces the
347
+ // user-facing, per-dialect db type. See `app.db` / crud below.
348
+ const userDb = db as unknown as DbFor<TSchema>
349
+ const auth = createAuth(
350
+ db,
351
+ withEmailAuthDefaults(config.auth, email, Boolean(options.email)),
352
+ dialect,
353
+ )
354
+ // Internal routers consume the narrow AuthSessionResolver contract, not the
355
+ // raw better-auth instance. app.auth still exposes `auth` unchanged.
356
+ const authResolver = toAuthSessionResolver(auth)
357
+ const resolvedAccess = validateAndResolveAccess(
358
+ options.schema,
359
+ options.access,
360
+ )
361
+ const realtimeBufferSize =
362
+ typeof config.realtime === 'object'
363
+ ? config.realtime.bufferSize
364
+ : undefined
365
+ const redisUrl =
366
+ config.realtime && !introspect
367
+ ? resolveRealtimeRedisUrl(config.realtime, env)
368
+ : undefined
369
+ const broker = config.realtime
370
+ ? redisUrl
371
+ ? createRedisRealtimeBroker({
372
+ access: resolvedAccess,
373
+ redis: () => {
374
+ // Redis pub/sub requires a dedicated connection (subscribe puts the client into
375
+ // a restricted state). We use one client for commands and a second for subscribe.
376
+ const cmdClient = new Bun.RedisClient(redisUrl)
377
+ const subClient = new Bun.RedisClient(redisUrl)
378
+ return {
379
+ incr: (key: string) => cmdClient.incr(key),
380
+ publish: (channel: string, message: string) =>
381
+ cmdClient.publish(channel, message),
382
+ subscribe: (channel: string, listener: (msg: string) => void) =>
383
+ subClient.subscribe(channel, listener),
384
+ lpush: (key: string, value: string) =>
385
+ cmdClient.lpush(key, value),
386
+ ltrim: (key: string, start: number, stop: number) =>
387
+ cmdClient.ltrim(key, start, stop),
388
+ lrange: (key: string, start: number, stop: number) =>
389
+ cmdClient.lrange(key, start, stop),
390
+ close: () => {
391
+ cmdClient.close()
392
+ subClient.close()
393
+ },
394
+ }
395
+ },
396
+ bufferSize: realtimeBufferSize,
397
+ })
398
+ : createRealtimeBroker({
399
+ access: resolvedAccess,
400
+ bufferSize: realtimeBufferSize,
401
+ })
323
402
  : undefined
324
- const broker = config.realtime
325
- ? redisUrl
326
- ? createRedisRealtimeBroker({
327
- access: resolvedAccess,
328
- redis: () => {
329
- // Redis pub/sub requires a dedicated connection (subscribe puts the client into
330
- // a restricted state). We use one client for commands and a second for subscribe.
331
- const cmdClient = new Bun.RedisClient(redisUrl)
332
- const subClient = new Bun.RedisClient(redisUrl)
333
- return {
334
- incr: (key: string) => cmdClient.incr(key),
335
- publish: (channel: string, message: string) =>
336
- cmdClient.publish(channel, message),
337
- subscribe: (channel: string, listener: (msg: string) => void) =>
338
- subClient.subscribe(channel, listener),
339
- lpush: (key: string, value: string) =>
340
- cmdClient.lpush(key, value),
341
- ltrim: (key: string, start: number, stop: number) =>
342
- cmdClient.ltrim(key, start, stop),
343
- lrange: (key: string, start: number, stop: number) =>
344
- cmdClient.lrange(key, start, stop),
345
- close: () => {
346
- cmdClient.close()
347
- subClient.close()
348
- },
349
- }
350
- },
351
- bufferSize: realtimeBufferSize,
352
- })
353
- : createRealtimeBroker({
354
- access: resolvedAccess,
355
- bufferSize: realtimeBufferSize,
403
+ const realtime = createRealtimeFacade<TSchema>(broker)
404
+ const crudRouter = buildCrudRouter(options.schema, userDb, {
405
+ auth: authResolver,
406
+ access: resolvedAccess,
407
+ idempotency: options.idempotency,
408
+ realtime,
409
+ })
410
+ const realtimeRouter = broker
411
+ ? buildRealtimeRouter(broker, {
412
+ auth: authResolver,
413
+ keepaliveMs:
414
+ typeof config.realtime === 'object'
415
+ ? config.realtime.keepaliveMs
416
+ : undefined,
356
417
  })
357
- : undefined
358
- const realtime = createRealtimeFacade<TSchema>(broker)
359
- const crudRouter = buildCrudRouter(options.schema, userDb, {
360
- auth: authResolver,
361
- access: resolvedAccess,
362
- idempotency: options.idempotency,
363
- realtime,
364
- })
365
- const realtimeRouter = broker
366
- ? buildRealtimeRouter(broker, {
367
- auth: authResolver,
368
- keepaliveMs:
369
- typeof config.realtime === 'object'
370
- ? config.realtime.keepaliveMs
371
- : undefined,
372
- })
373
- : undefined
374
- const registry = createBucketStorages(config.storage)
375
- const lifecycle = new Lifecycle()
376
- if (broker) lifecycle.add(() => broker.close())
377
- const storageRouter = buildBucketStorageRouter({
378
- registry,
379
- db,
380
- auth: authResolver,
381
- })
382
- const storage: StorageFacade = {
383
- async delete(fileId) {
384
- const bucketName = fileId.split('/')[0] ?? ''
385
- const entry = registry.get(bucketName)
386
- if (entry) {
387
- await deleteFileWithDerivatives(entry.adapter, db, fileId)
388
- } else {
389
- // Unknown bucket: no adapter to clean, but still drop the meta row.
390
- await deleteFileMetaRow(db, fileId)
391
- }
392
- },
393
- bucket(name) {
394
- return registry.get(name)?.adapter
395
- },
396
- sweep(olderThanMs = DEFAULT_PENDING_TTL_MS) {
397
- return sweepOrphans(registry, db, olderThanMs)
398
- },
399
- }
400
- const jobRunner = jobsDefs
401
- ? createJobRunner({
402
- db,
403
- defs: jobsDefs,
404
- ctx: { db: userDb, env, email, storage, realtime },
405
- })
406
- : undefined
407
- const jobs = {
408
- async enqueue(name: string, input?: unknown, opts?: EnqueueOptions) {
409
- if (!jobsDefs) {
410
- throw new Error(
411
- '[bunderstack] no jobs configured — add a `jobs` key to createBunderstack',
412
- )
413
- }
414
- const result = await enqueueJob(db, jobsDefs, name, input, opts)
415
- return result
416
- },
417
- tick(now?: number) {
418
- return jobRunner ? jobRunner.tick(now) : Promise.resolve()
419
- },
420
- }
421
- if (jobRunner) jobRunner.setJobsFacade(jobs)
422
- const startWorker = async (
423
- options: AppStartWorkerOptions = {},
424
- ): Promise<WorkerHandle> => {
425
- if (!jobRunner) {
426
- throw new Error('[bunderstack] no queue jobs configured')
427
- }
428
- if (lifecycle.status !== 'ready') {
429
- throw new Error('[bunderstack] application lifecycle is closed')
430
- }
431
- const signal = options.signal
432
- ? AbortSignal.any([lifecycle.signal, options.signal])
433
- : lifecycle.signal
434
- const handle = startJobWorker({
435
- ...options,
436
- signal,
437
- tick: (now) => jobRunner.tick(now),
418
+ : undefined
419
+ const registry = createBucketStorages(config.storage)
420
+ if (broker) lifecycle.add(() => broker.close())
421
+ const storageRouter = buildBucketStorageRouter({
422
+ registry,
423
+ db,
424
+ auth: authResolver,
438
425
  })
439
- const unregister = lifecycle.add(() => handle.close())
440
- void handle.closed.finally(unregister)
441
- return handle
442
- }
443
- const startCronScheduler = async (
444
- options: AppStartCronSchedulerOptions = {},
445
- ): Promise<LocalCronScheduler> => {
446
- const cron = Object.entries(jobsDefs ?? {}).flatMap(([name, definition]) =>
447
- definition.kind === 'cron'
448
- ? [{ name, schedule: definition.schedule }]
449
- : [],
450
- )
451
- if (cron.length === 0) {
452
- throw new Error('[bunderstack] no cron tasks configured')
453
- }
454
- if (lifecycle.status !== 'ready') {
455
- throw new Error('[bunderstack] application lifecycle is closed')
426
+ const storage: StorageFacade = {
427
+ async delete(fileId) {
428
+ const bucketName = fileId.split('/')[0] ?? ''
429
+ const entry = registry.get(bucketName)
430
+ if (entry) {
431
+ await deleteFileWithDerivatives(entry.adapter, db, fileId)
432
+ } else {
433
+ // Unknown bucket: no adapter to clean, but still drop the meta row.
434
+ await deleteFileMetaRow(db, fileId)
435
+ }
436
+ },
437
+ bucket(name) {
438
+ return registry.get(name)?.adapter
439
+ },
440
+ sweep(olderThanMs = DEFAULT_PENDING_TTL_MS) {
441
+ return sweepOrphans(registry, db, olderThanMs)
442
+ },
456
443
  }
457
- const scheduler = startLocalCronScheduler({
458
- cron,
459
- onError: options.onError,
460
- runSlot: async (name, slot) => {
461
- await runCronSlot({
444
+ const jobRunner = jobsDefs
445
+ ? createJobRunner({
462
446
  db,
463
- defs: jobsDefs!,
447
+ defs: jobsDefs,
464
448
  ctx: { db: userDb, env, email, storage, realtime },
465
- name,
466
- slot,
467
- now: Date.now(),
468
449
  })
450
+ : undefined
451
+ const jobs = {
452
+ async enqueue(name: string, input?: unknown, opts?: EnqueueOptions) {
453
+ if (!jobsDefs) {
454
+ throw new Error(
455
+ '[bunderstack] no jobs configured — add a `jobs` key to createBunderstack',
456
+ )
457
+ }
458
+ const result = await enqueueJob(db, jobsDefs, name, input, opts)
459
+ return result
460
+ },
461
+ tick(now?: number) {
462
+ return jobRunner ? jobRunner.tick(now) : Promise.resolve()
469
463
  },
470
- })
471
- const unregister = lifecycle.add(() => scheduler.close())
472
- try {
473
- await scheduler.tick()
474
- } catch (error) {
475
- unregister()
476
- await scheduler.close()
477
- throw error
478
464
  }
479
- return scheduler
480
- }
481
- const runWorker = async (
482
- options: AppRunWorkerOptions = {},
483
- ): Promise<void> => {
484
- const handle = await startWorker(options)
485
- try {
465
+ if (jobRunner) jobRunner.setJobsFacade(jobs)
466
+ const startWorker = async (
467
+ options: AppStartWorkerOptions = {},
468
+ ): Promise<WorkerHandle> => {
469
+ if (!jobRunner) {
470
+ throw new Error('[bunderstack] no queue jobs configured')
471
+ }
472
+ if (lifecycle.status !== 'ready') {
473
+ throw new Error('[bunderstack] application lifecycle is closed')
474
+ }
486
475
  const signal = options.signal
487
476
  ? AbortSignal.any([lifecycle.signal, options.signal])
488
477
  : lifecycle.signal
489
- await waitForWorkerShutdown(signal, !options.signal)
490
- } finally {
491
- await handle.close()
492
- await lifecycle.close()
478
+ const handle = startJobWorker({
479
+ ...options,
480
+ signal,
481
+ tick: (now) => jobRunner.tick(now),
482
+ })
483
+ const unregister = lifecycle.add(() => handle.close())
484
+ void handle.closed.finally(unregister)
485
+ return handle
493
486
  }
494
- }
495
- const trpcRouter: AnyRouter | undefined =
496
- typeof options.trpc === 'function'
497
- ? options.trpc(createTRPC<TSchema, ValidatedEnv<TEnv>>())
498
- : options.trpc
499
- const trpcHandler = trpcRouter
500
- ? (req: Request) =>
501
- fetchRequestHandler({
502
- endpoint: '/api/trpc',
503
- req,
504
- router: trpcRouter,
505
- createContext: async () => ({
506
- db: userDb,
507
- user: await resolveAccessUser(authResolver, req.headers),
508
- env,
509
- email,
510
- jobs,
511
- realtime,
487
+ const startCronScheduler = async (
488
+ options: AppStartCronSchedulerOptions = {},
489
+ ): Promise<LocalCronScheduler> => {
490
+ const cron = Object.entries(jobsDefs ?? {}).flatMap(
491
+ ([name, definition]) =>
492
+ definition.kind === 'cron'
493
+ ? [{ name, schedule: definition.schedule }]
494
+ : [],
495
+ )
496
+ if (cron.length === 0) {
497
+ throw new Error('[bunderstack] no cron tasks configured')
498
+ }
499
+ if (lifecycle.status !== 'ready') {
500
+ throw new Error('[bunderstack] application lifecycle is closed')
501
+ }
502
+ const scheduler = startLocalCronScheduler({
503
+ cron,
504
+ onError: options.onError,
505
+ runSlot: async (name, slot) => {
506
+ await runCronSlot({
507
+ db,
508
+ defs: jobsDefs!,
509
+ ctx: { db: userDb, env, email, storage, realtime },
510
+ name,
511
+ slot,
512
+ now: Date.now(),
513
+ })
514
+ },
515
+ })
516
+ const unregister = lifecycle.add(() => scheduler.close())
517
+ try {
518
+ await scheduler.tick()
519
+ } catch (error) {
520
+ unregister()
521
+ await scheduler.close()
522
+ throw error
523
+ }
524
+ return scheduler
525
+ }
526
+ const runWorker = async (
527
+ options: AppRunWorkerOptions = {},
528
+ ): Promise<void> => {
529
+ const handle = await startWorker(options)
530
+ try {
531
+ const signal = options.signal
532
+ ? AbortSignal.any([lifecycle.signal, options.signal])
533
+ : lifecycle.signal
534
+ await waitForWorkerShutdown(signal, !options.signal)
535
+ } finally {
536
+ await handle.close()
537
+ await lifecycle.close()
538
+ }
539
+ }
540
+ const trpcRouter: AnyRouter | undefined =
541
+ typeof options.trpc === 'function'
542
+ ? options.trpc(createTRPC<TSchema, ValidatedEnv<TEnv>>())
543
+ : options.trpc
544
+ const trpcHandler = trpcRouter
545
+ ? (req: Request) =>
546
+ fetchRequestHandler({
547
+ endpoint: '/api/trpc',
512
548
  req,
513
- }),
514
- })
515
- : undefined
516
- const cronRouter =
517
- env.BUNDERSTACK_CRON_SECRET
549
+ router: trpcRouter,
550
+ createContext: async () => ({
551
+ db: userDb,
552
+ user: await resolveAccessUser(authResolver, req.headers),
553
+ env,
554
+ email,
555
+ jobs,
556
+ realtime,
557
+ req,
558
+ }),
559
+ })
560
+ : undefined
561
+ const cronRouter = env.BUNDERSTACK_CRON_SECRET
518
562
  ? buildCronRouter({
519
563
  db,
520
564
  defs: jobsDefs ?? {},
@@ -523,69 +567,81 @@ export async function createBunderstack<
523
567
  storage,
524
568
  })
525
569
  : undefined
526
- const { handler, router } = buildHandler({
527
- crudRouter,
528
- authHandler: (req) => auth.handler(req),
529
- storageRouter,
530
- realtimeRouter,
531
- trpcHandler,
532
- cronRouter,
533
- rateLimit: options.rateLimit,
534
- })
570
+ const { handler, router } = buildHandler({
571
+ crudRouter,
572
+ authHandler: (req) => auth.handler(req),
573
+ storageRouter,
574
+ realtimeRouter,
575
+ trpcHandler,
576
+ cronRouter,
577
+ rateLimit: options.rateLimit,
578
+ })
535
579
 
536
- const app: BunderstackApp<
537
- TSchema,
538
- TAccess,
539
- BucketNamesOf<TStorage>,
540
- TEnv,
541
- AnyRouter | undefined,
542
- JobsDefs | undefined
543
- > = {
544
- handler,
545
- // Internal tables live on the runtime db but stay out of the public type.
546
- db: userDb,
547
- auth,
548
- storage,
549
- router,
550
- env,
551
- email,
552
- realtime,
553
- // Runtime facade is untyped (JobsRuntimeFacade); the generic-typed field
554
- // narrows `enqueue` per-app from the declared job defs — same relationship
555
- // as `userDb` above.
556
- jobs: jobs as never,
557
- startWorker,
558
- runWorker,
559
- startCronScheduler,
560
- close: () => lifecycle.close(),
561
- get status() {
562
- return lifecycle.status
563
- },
564
- signal: lifecycle.signal,
565
- trpcRouter,
566
- manifest: buildManifest({
567
- schema: options.schema,
580
+ const app: BunderstackApp<
581
+ TSchema,
582
+ TAccess,
583
+ BucketNamesOf<TStorage>,
584
+ TEnv,
585
+ AnyRouter | undefined,
586
+ JobsDefs | undefined
587
+ > = {
588
+ handler,
589
+ // Internal tables live on the runtime db but stay out of the public type.
590
+ db: userDb,
591
+ auth,
592
+ storage,
593
+ router,
594
+ env,
595
+ email,
596
+ realtime,
597
+ // Runtime facade is untyped (JobsRuntimeFacade); the generic-typed field
598
+ // narrows `enqueue` per-app from the declared job defs — same relationship
599
+ // as `userDb` above.
600
+ jobs: jobs as never,
601
+ startWorker,
602
+ runWorker,
603
+ startCronScheduler,
604
+ close: () => lifecycle.close(),
605
+ get status() {
606
+ return lifecycle.status
607
+ },
608
+ signal: lifecycle.signal,
609
+ trpcRouter,
610
+ manifest: buildManifest({
611
+ schema: options.schema,
612
+ dialect,
613
+ storage: config.storage,
614
+ envConfig: options.env as EnvConfigInput | undefined,
615
+ realtime: Boolean(config.realtime),
616
+ jobs: jobsDefs,
617
+ }),
618
+ }
619
+
620
+ // Hidden handle for the optional `bunderstack/provision` entry. Kept off the
621
+ // public type so provisioning stays opt-in (and drizzle-kit out of this
622
+ // module graph).
623
+ ;(app as WithProvisionInternals)[PROVISION_INTERNALS] = {
624
+ db,
625
+ schema: mergedSchema,
626
+ databaseUrl: config.database.url,
627
+ migrationsFolder: config.database.migrations,
568
628
  dialect,
569
- storage: config.storage,
570
- envConfig: options.env as EnvConfigInput | undefined,
571
- realtime: Boolean(config.realtime),
572
- jobs: jobsDefs,
573
- }),
574
- }
629
+ driver,
630
+ adapter: config.database.adapter,
631
+ }
575
632
 
576
- // Hidden handle for the optional `bunderstack/provision` entry. Kept off the
577
- // public type so provisioning stays opt-in (and drizzle-kit out of this
578
- // module graph).
579
- ;(app as WithProvisionInternals)[PROVISION_INTERNALS] = {
580
- db,
581
- schema: mergedSchema,
582
- databaseUrl: config.database.url,
583
- migrationsFolder: config.database.migrations,
584
- dialect,
585
- driver,
633
+ return app
634
+ } catch (cause) {
635
+ try {
636
+ await lifecycle.close()
637
+ } catch (cleanupCause) {
638
+ throw new AggregateError(
639
+ [cause, cleanupCause],
640
+ '[bunderstack] application initialization failed and cleanup failed',
641
+ )
642
+ }
643
+ throw cause
586
644
  }
587
-
588
- return app
589
645
  }
590
646
 
591
647
  export { MAX_LIST_LIMIT } from './list-query'
@@ -652,6 +708,12 @@ export {
652
708
  asTypeId,
653
709
  } from './typeid'
654
710
  export type { TypeId } from './typeid'
711
+ export type {
712
+ DatabaseAdapter,
713
+ DatabaseConnectOptions,
714
+ DatabaseConnection,
715
+ DatabaseConnectionResult,
716
+ } from './database/adapter'
655
717
  export type { StorageAdapter } from './storage/index'
656
718
  export type {
657
719
  StorageConfigInput,