bunderstack 0.6.1 → 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,20 +44,13 @@ 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
56
  import { deleteFileWithDerivatives } from './storage/delete'
@@ -54,6 +58,7 @@ import { deleteFileMetaRow } from './storage/file-meta'
54
58
  import { createBucketStorages } from './storage/registry'
55
59
  import { buildBucketStorageRouter } from './storage/router'
56
60
  import { sweepOrphans } from './storage/sweep'
61
+ import { createTRPC, type BunderstackTRPC } from './trpc'
57
62
 
58
63
  type AuthInstance = ReturnType<typeof createAuth>
59
64
 
@@ -114,7 +119,6 @@ export type BucketNamesOf<TStorage> = TStorage extends {
114
119
  ? keyof B & string
115
120
  : string
116
121
 
117
-
118
122
  export type BunderstackApp<
119
123
  TSchema extends Record<string, unknown>,
120
124
  TAccess extends Record<string, TableAccessInput> | undefined = undefined,
@@ -135,7 +139,11 @@ export type BunderstackApp<
135
139
  /** Email facade; always present — send() throws when email isn't configured. */
136
140
  email: EmailFacade
137
141
  /** Job queue facade; always present — enqueue throws when jobs aren't configured. */
138
- jobs: JobsFacade<TJobsDefs extends JobsDefs ? TJobsDefs : Record<never, never>>
142
+ jobs: JobsFacade<
143
+ TJobsDefs extends JobsDefs ? TJobsDefs : Record<never, never>
144
+ >
145
+ /** Typed custom row publication; enabled=false/no-op when realtime is off. */
146
+ realtime: RealtimeFacade<TSchema>
139
147
  startWorker(options?: AppStartWorkerOptions): Promise<WorkerHandle>
140
148
  /** Run a queue worker until aborted, then close the application. */
141
149
  runWorker(options?: AppRunWorkerOptions): Promise<void>
@@ -183,7 +191,16 @@ export function createBunderstack<
183
191
  /** Builder callback receiving the pre-wired `j` instance. */
184
192
  jobs: (j: BunderstackJobsBuilder<TSchema, ValidatedEnv<TEnv>>) => TJobsDefs
185
193
  },
186
- ): 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
+ >
187
204
  export function createBunderstack<
188
205
  TSchema extends Record<string, unknown>,
189
206
  const TAccess extends Record<string, TableAccessInput> | undefined =
@@ -199,7 +216,16 @@ export function createBunderstack<
199
216
  /** Prebuilt job definitions (escape hatch for multi-file setups). */
200
217
  jobs?: TJobsDefs
201
218
  },
202
- ): 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
+ >
203
229
  export function createBunderstack<
204
230
  TSchema extends Record<string, unknown>,
205
231
  const TAccess extends Record<string, TableAccessInput> | undefined =
@@ -215,7 +241,16 @@ export function createBunderstack<
215
241
  /** Builder callback receiving the pre-wired `j` instance. */
216
242
  jobs: (j: BunderstackJobsBuilder<TSchema, ValidatedEnv<TEnv>>) => TJobsDefs
217
243
  },
218
- ): 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
+ >
219
254
  export function createBunderstack<
220
255
  TSchema extends Record<string, unknown>,
221
256
  const TAccess extends Record<string, TableAccessInput> | undefined =
@@ -231,7 +266,16 @@ export function createBunderstack<
231
266
  /** Prebuilt job definitions (escape hatch for multi-file setups). */
232
267
  jobs?: TJobsDefs
233
268
  },
234
- ): 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
+ >
235
279
  export async function createBunderstack<
236
280
  TSchema extends Record<string, unknown>,
237
281
  const TAccess extends Record<string, TableAccessInput> | undefined =
@@ -275,311 +319,329 @@ export async function createBunderstack<
275
319
  cronConfigured: true,
276
320
  })
277
321
  const config = resolveConfig(options, env)
278
- // Introspection mode (BUNDERSTACK_INTROSPECT=1): deployment platforms import
279
- // the app declaration only to read `app.manifest`. The boot must never touch
280
- // the outside world — force an in-memory db (':memory:' is valid for both
281
- // 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.
282
324
  const introspect = process.env.BUNDERSTACK_INTROSPECT === '1'
283
- if (introspect) {
284
- config.database.url = ':memory:'
285
- config.database.authToken = undefined
286
- }
287
325
  const email = createEmail(options.email, { env })
288
326
  // Merge bunderstack's internal tables (file-meta, idempotency) into the
289
327
  // schema used for the db client + provisioning. CRUD/access stay on the USER
290
328
  // schema so internal tables never get a CRUD route.
291
329
  const mergedSchema = withInternalTables(options.schema)
292
- const { db, driver } = await createDb(mergedSchema, {
330
+ const lifecycle = new Lifecycle()
331
+ const {
332
+ db,
333
+ driver,
334
+ close: closeDatabase,
335
+ } = await createDb(mergedSchema, {
293
336
  ...config.database,
294
337
  dialect,
338
+ introspect,
295
339
  })
296
- // `db` is typed with the merged schema (user tables + internal tables) so the
297
- // storage/idempotency code can query the internal tables. The public surface
298
- // and CRUD only expose the USER schema. TS can widen the merged-schema db type
299
- // on its own (storage/auth pass `db` directly), but it can't *narrow* a
300
- // generic schema view, so this single intentional cast produces the
301
- // user-facing, per-dialect db type. See `app.db` / crud below.
302
- const userDb = db as unknown as DbFor<TSchema>
303
- const auth = createAuth(
304
- db,
305
- withEmailAuthDefaults(config.auth, email, Boolean(options.email)),
306
- dialect,
307
- )
308
- // Internal routers consume the narrow AuthSessionResolver contract, not the
309
- // raw better-auth instance. app.auth still exposes `auth` unchanged.
310
- const authResolver = toAuthSessionResolver(auth)
311
- const resolvedAccess = validateAndResolveAccess(
312
- options.schema,
313
- options.access,
314
- )
315
- const realtimeBufferSize =
316
- typeof config.realtime === 'object' ? config.realtime.bufferSize : undefined
317
- const redisUrl =
318
- config.realtime && !introspect
319
- ? 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
+ })
320
402
  : undefined
321
- const broker = config.realtime
322
- ? redisUrl
323
- ? createRedisRealtimeBroker({
324
- access: resolvedAccess,
325
- redis: () => {
326
- // Redis pub/sub requires a dedicated connection (subscribe puts the client into
327
- // a restricted state). We use one client for commands and a second for subscribe.
328
- const cmdClient = new Bun.RedisClient(redisUrl)
329
- const subClient = new Bun.RedisClient(redisUrl)
330
- return {
331
- incr: (key: string) => cmdClient.incr(key),
332
- publish: (channel: string, message: string) =>
333
- cmdClient.publish(channel, message),
334
- subscribe: (channel: string, listener: (msg: string) => void) =>
335
- subClient.subscribe(channel, listener),
336
- lpush: (key: string, value: string) =>
337
- cmdClient.lpush(key, value),
338
- ltrim: (key: string, start: number, stop: number) =>
339
- cmdClient.ltrim(key, start, stop),
340
- lrange: (key: string, start: number, stop: number) =>
341
- cmdClient.lrange(key, start, stop),
342
- close: () => {
343
- cmdClient.close()
344
- subClient.close()
345
- },
346
- }
347
- },
348
- bufferSize: realtimeBufferSize,
349
- })
350
- : createRealtimeBroker({
351
- access: resolvedAccess,
352
- 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,
353
417
  })
354
- : undefined
355
- const crudRouter = buildCrudRouter(options.schema, userDb, {
356
- auth: authResolver,
357
- access: resolvedAccess,
358
- idempotency: options.idempotency,
359
- broker,
360
- })
361
- const realtimeRouter = broker
362
- ? buildRealtimeRouter(broker, {
363
- auth: authResolver,
364
- keepaliveMs:
365
- typeof config.realtime === 'object'
366
- ? config.realtime.keepaliveMs
367
- : undefined,
368
- })
369
- : undefined
370
- const registry = createBucketStorages(config.storage)
371
- const lifecycle = new Lifecycle()
372
- if (broker) lifecycle.add(() => broker.close())
373
- const storageRouter = buildBucketStorageRouter({
374
- registry,
375
- db,
376
- auth: authResolver,
377
- })
378
- const storage: StorageFacade = {
379
- async delete(fileId) {
380
- const bucketName = fileId.split('/')[0] ?? ''
381
- const entry = registry.get(bucketName)
382
- if (entry) {
383
- await deleteFileWithDerivatives(entry.adapter, db, fileId)
384
- } else {
385
- // Unknown bucket: no adapter to clean, but still drop the meta row.
386
- await deleteFileMetaRow(db, fileId)
387
- }
388
- },
389
- bucket(name) {
390
- return registry.get(name)?.adapter
391
- },
392
- sweep(olderThanMs = DEFAULT_PENDING_TTL_MS) {
393
- return sweepOrphans(registry, db, olderThanMs)
394
- },
395
- }
396
- const jobRunner = jobsDefs
397
- ? createJobRunner({
398
- db,
399
- defs: jobsDefs,
400
- ctx: { db: userDb, env, email, storage },
401
- })
402
- : undefined
403
- const jobs = {
404
- async enqueue(name: string, input?: unknown, opts?: EnqueueOptions) {
405
- if (!jobsDefs) {
406
- throw new Error(
407
- '[bunderstack] no jobs configured — add a `jobs` key to createBunderstack',
408
- )
409
- }
410
- const result = await enqueueJob(db, jobsDefs, name, input, opts)
411
- return result
412
- },
413
- tick(now?: number) {
414
- return jobRunner ? jobRunner.tick(now) : Promise.resolve()
415
- },
416
- }
417
- if (jobRunner) jobRunner.setJobsFacade(jobs)
418
- const startWorker = async (
419
- options: AppStartWorkerOptions = {},
420
- ): Promise<WorkerHandle> => {
421
- if (!jobRunner) {
422
- throw new Error('[bunderstack] no queue jobs configured')
423
- }
424
- if (lifecycle.status !== 'ready') {
425
- throw new Error('[bunderstack] application lifecycle is closed')
426
- }
427
- const signal = options.signal
428
- ? AbortSignal.any([lifecycle.signal, options.signal])
429
- : lifecycle.signal
430
- const handle = startJobWorker({
431
- ...options,
432
- signal,
433
- 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,
434
425
  })
435
- const unregister = lifecycle.add(() => handle.close())
436
- void handle.closed.finally(unregister)
437
- return handle
438
- }
439
- const startCronScheduler = async (
440
- options: AppStartCronSchedulerOptions = {},
441
- ): Promise<LocalCronScheduler> => {
442
- const cron = Object.entries(jobsDefs ?? {}).flatMap(([name, definition]) =>
443
- definition.kind === 'cron'
444
- ? [{ name, schedule: definition.schedule }]
445
- : [],
446
- )
447
- if (cron.length === 0) {
448
- throw new Error('[bunderstack] no cron tasks configured')
449
- }
450
- if (lifecycle.status !== 'ready') {
451
- 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
+ },
452
443
  }
453
- const scheduler = startLocalCronScheduler({
454
- cron,
455
- onError: options.onError,
456
- runSlot: async (name, slot) => {
457
- await runCronSlot({
444
+ const jobRunner = jobsDefs
445
+ ? createJobRunner({
458
446
  db,
459
- defs: jobsDefs!,
460
- ctx: { db: userDb, env, email, storage },
461
- name,
462
- slot,
463
- now: Date.now(),
447
+ defs: jobsDefs,
448
+ ctx: { db: userDb, env, email, storage, realtime },
464
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()
465
463
  },
466
- })
467
- const unregister = lifecycle.add(() => scheduler.close())
468
- try {
469
- await scheduler.tick()
470
- } catch (error) {
471
- unregister()
472
- await scheduler.close()
473
- throw error
474
464
  }
475
- return scheduler
476
- }
477
- const runWorker = async (
478
- options: AppRunWorkerOptions = {},
479
- ): Promise<void> => {
480
- const handle = await startWorker(options)
481
- 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
+ }
482
475
  const signal = options.signal
483
476
  ? AbortSignal.any([lifecycle.signal, options.signal])
484
477
  : lifecycle.signal
485
- await waitForWorkerShutdown(signal, !options.signal)
486
- } finally {
487
- await handle.close()
488
- 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
489
486
  }
490
- }
491
- const trpcRouter: AnyRouter | undefined =
492
- typeof options.trpc === 'function'
493
- ? options.trpc(createTRPC<TSchema, ValidatedEnv<TEnv>>())
494
- : options.trpc
495
- const trpcHandler = trpcRouter
496
- ? (req: Request) =>
497
- fetchRequestHandler({
498
- endpoint: '/api/trpc',
499
- req,
500
- router: trpcRouter,
501
- createContext: async () => ({
502
- db: userDb,
503
- user: await resolveAccessUser(authResolver, req.headers),
504
- env,
505
- email,
506
- jobs,
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',
507
548
  req,
508
- }),
509
- })
510
- : undefined
511
- const cronRouter =
512
- 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
513
562
  ? buildCronRouter({
514
563
  db,
515
564
  defs: jobsDefs ?? {},
516
- ctx: { db: userDb, env, email, storage },
565
+ ctx: { db: userDb, env, email, storage, realtime },
517
566
  secret: env.BUNDERSTACK_CRON_SECRET,
518
567
  storage,
519
568
  })
520
569
  : undefined
521
- const { handler, router } = buildHandler({
522
- crudRouter,
523
- authHandler: (req) => auth.handler(req),
524
- storageRouter,
525
- realtimeRouter,
526
- trpcHandler,
527
- cronRouter,
528
- rateLimit: options.rateLimit,
529
- })
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
+ })
530
579
 
531
- const app: BunderstackApp<
532
- TSchema,
533
- TAccess,
534
- BucketNamesOf<TStorage>,
535
- TEnv,
536
- AnyRouter | undefined,
537
- JobsDefs | undefined
538
- > = {
539
- handler,
540
- // Internal tables live on the runtime db but stay out of the public type.
541
- db: userDb,
542
- auth,
543
- storage,
544
- router,
545
- env,
546
- email,
547
- // Runtime facade is untyped (JobsRuntimeFacade); the generic-typed field
548
- // narrows `enqueue` per-app from the declared job defs — same relationship
549
- // as `userDb` above.
550
- jobs: jobs as never,
551
- startWorker,
552
- runWorker,
553
- startCronScheduler,
554
- close: () => lifecycle.close(),
555
- get status() {
556
- return lifecycle.status
557
- },
558
- signal: lifecycle.signal,
559
- trpcRouter,
560
- manifest: buildManifest({
561
- 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,
562
628
  dialect,
563
- storage: config.storage,
564
- envConfig: options.env as EnvConfigInput | undefined,
565
- realtime: Boolean(config.realtime),
566
- jobs: jobsDefs,
567
- }),
568
- }
629
+ driver,
630
+ adapter: config.database.adapter,
631
+ }
569
632
 
570
- // Hidden handle for the optional `bunderstack/provision` entry. Kept off the
571
- // public type so provisioning stays opt-in (and drizzle-kit out of this
572
- // module graph).
573
- ;(app as WithProvisionInternals)[PROVISION_INTERNALS] = {
574
- db,
575
- schema: mergedSchema,
576
- databaseUrl: config.database.url,
577
- migrationsFolder: config.database.migrations,
578
- dialect,
579
- 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
580
644
  }
581
-
582
- return app
583
645
  }
584
646
 
585
647
  export { MAX_LIST_LIMIT } from './list-query'
@@ -646,6 +708,12 @@ export {
646
708
  asTypeId,
647
709
  } from './typeid'
648
710
  export type { TypeId } from './typeid'
711
+ export type {
712
+ DatabaseAdapter,
713
+ DatabaseConnectOptions,
714
+ DatabaseConnection,
715
+ DatabaseConnectionResult,
716
+ } from './database/adapter'
649
717
  export type { StorageAdapter } from './storage/index'
650
718
  export type {
651
719
  StorageConfigInput,
@@ -654,3 +722,6 @@ export type {
654
722
  } from './storage/buckets'
655
723
  // StorageFacade is declared+exported inline above.
656
724
  export type { TransformSpec } from './storage/thumbnails'
725
+
726
+ export type { RealtimeAction } from './realtime/index'
727
+ export type { RealtimeFacade, SchemaTable } from './realtime/facade'