bunderstack 0.7.0 → 0.9.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,25 @@ 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 {
54
+ createRealtimeFacade,
55
+ type RealtimeFacade,
56
+ type RealtimeTransport,
57
+ } from './realtime/facade'
50
58
  import { createRealtimeBroker, buildRealtimeRouter } from './realtime/index'
51
59
  import { createRedisRealtimeBroker } from './realtime/redis'
52
- import { createRealtimeFacade, type RealtimeFacade } from './realtime/facade'
53
60
  import { deleteFileWithDerivatives } from './storage/delete'
54
61
  import { deleteFileMetaRow } from './storage/file-meta'
55
62
  import { createBucketStorages } from './storage/registry'
56
63
  import { buildBucketStorageRouter } from './storage/router'
57
64
  import { sweepOrphans } from './storage/sweep'
65
+ import { createTRPC, type BunderstackTRPC } from './trpc'
58
66
 
59
67
  type AuthInstance = ReturnType<typeof createAuth>
60
68
 
@@ -89,9 +97,9 @@ const DEFAULT_PENDING_TTL_MS = 30 * 60_000
89
97
  * must also clean the file-meta row.
90
98
  */
91
99
  export interface StorageFacade {
92
- /** Delete an object, its transform derivatives, and its file-meta row. `fileId` is `<bucket>/<id>`. */
100
+ /** Delete a file row and purge all underlying storage derivatives. */
93
101
  delete(fileId: string): Promise<void>
94
- /** Get the raw adapter for a bucket, or `undefined` if it isn't declared. */
102
+ /** Low-level access to the underlying storage adapter for a bucket. */
95
103
  bucket(name: string): StorageAdapter | undefined
96
104
  /**
97
105
  * Reap stale `pending` uploads older than `olderThanMs` (default 30m). Runs
@@ -102,7 +110,15 @@ export interface StorageFacade {
102
110
  }
103
111
 
104
112
  export type AppStartWorkerOptions = Omit<StartWorkerOptions, 'tick'>
105
- export type AppRunWorkerOptions = AppStartWorkerOptions
113
+ export type AppRunWorkerOptions = AppStartWorkerOptions & {
114
+ /**
115
+ * Permit process-local realtime in a standalone worker.
116
+ *
117
+ * Use only when job handlers never call ctx.realtime.publish(). Publications
118
+ * made through the memory broker cannot reach SSE clients in another process.
119
+ */
120
+ allowProcessLocalRealtime?: boolean
121
+ }
106
122
  export type AppStartCronSchedulerOptions = Pick<
107
123
  LocalCronSchedulerOptions,
108
124
  'onError'
@@ -115,7 +131,6 @@ export type BucketNamesOf<TStorage> = TStorage extends {
115
131
  ? keyof B & string
116
132
  : string
117
133
 
118
-
119
134
  export type BunderstackApp<
120
135
  TSchema extends Record<string, unknown>,
121
136
  TAccess extends Record<string, TableAccessInput> | undefined = undefined,
@@ -136,7 +151,9 @@ export type BunderstackApp<
136
151
  /** Email facade; always present — send() throws when email isn't configured. */
137
152
  email: EmailFacade
138
153
  /** Job queue facade; always present — enqueue throws when jobs aren't configured. */
139
- jobs: JobsFacade<TJobsDefs extends JobsDefs ? TJobsDefs : Record<never, never>>
154
+ jobs: JobsFacade<
155
+ TJobsDefs extends JobsDefs ? TJobsDefs : Record<never, never>
156
+ >
140
157
  /** Typed custom row publication; enabled=false/no-op when realtime is off. */
141
158
  realtime: RealtimeFacade<TSchema>
142
159
  startWorker(options?: AppStartWorkerOptions): Promise<WorkerHandle>
@@ -186,7 +203,16 @@ export function createBunderstack<
186
203
  /** Builder callback receiving the pre-wired `j` instance. */
187
204
  jobs: (j: BunderstackJobsBuilder<TSchema, ValidatedEnv<TEnv>>) => TJobsDefs
188
205
  },
189
- ): Promise<BunderstackApp<TSchema, TAccess, BucketNamesOf<TStorage>, TEnv, TRouter, TJobsDefs>>
206
+ ): Promise<
207
+ BunderstackApp<
208
+ TSchema,
209
+ TAccess,
210
+ BucketNamesOf<TStorage>,
211
+ TEnv,
212
+ TRouter,
213
+ TJobsDefs
214
+ >
215
+ >
190
216
  export function createBunderstack<
191
217
  TSchema extends Record<string, unknown>,
192
218
  const TAccess extends Record<string, TableAccessInput> | undefined =
@@ -202,7 +228,16 @@ export function createBunderstack<
202
228
  /** Prebuilt job definitions (escape hatch for multi-file setups). */
203
229
  jobs?: TJobsDefs
204
230
  },
205
- ): Promise<BunderstackApp<TSchema, TAccess, BucketNamesOf<TStorage>, TEnv, TRouter, TJobsDefs>>
231
+ ): Promise<
232
+ BunderstackApp<
233
+ TSchema,
234
+ TAccess,
235
+ BucketNamesOf<TStorage>,
236
+ TEnv,
237
+ TRouter,
238
+ TJobsDefs
239
+ >
240
+ >
206
241
  export function createBunderstack<
207
242
  TSchema extends Record<string, unknown>,
208
243
  const TAccess extends Record<string, TableAccessInput> | undefined =
@@ -218,7 +253,16 @@ export function createBunderstack<
218
253
  /** Builder callback receiving the pre-wired `j` instance. */
219
254
  jobs: (j: BunderstackJobsBuilder<TSchema, ValidatedEnv<TEnv>>) => TJobsDefs
220
255
  },
221
- ): Promise<BunderstackApp<TSchema, TAccess, BucketNamesOf<TStorage>, TEnv, TRouter, TJobsDefs>>
256
+ ): Promise<
257
+ BunderstackApp<
258
+ TSchema,
259
+ TAccess,
260
+ BucketNamesOf<TStorage>,
261
+ TEnv,
262
+ TRouter,
263
+ TJobsDefs
264
+ >
265
+ >
222
266
  export function createBunderstack<
223
267
  TSchema extends Record<string, unknown>,
224
268
  const TAccess extends Record<string, TableAccessInput> | undefined =
@@ -234,7 +278,16 @@ export function createBunderstack<
234
278
  /** Prebuilt job definitions (escape hatch for multi-file setups). */
235
279
  jobs?: TJobsDefs
236
280
  },
237
- ): Promise<BunderstackApp<TSchema, TAccess, BucketNamesOf<TStorage>, TEnv, TRouter, TJobsDefs>>
281
+ ): Promise<
282
+ BunderstackApp<
283
+ TSchema,
284
+ TAccess,
285
+ BucketNamesOf<TStorage>,
286
+ TEnv,
287
+ TRouter,
288
+ TJobsDefs
289
+ >
290
+ >
238
291
  export async function createBunderstack<
239
292
  TSchema extends Record<string, unknown>,
240
293
  const TAccess extends Record<string, TableAccessInput> | undefined =
@@ -278,243 +331,271 @@ export async function createBunderstack<
278
331
  cronConfigured: true,
279
332
  })
280
333
  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).
334
+ // Adapters use Drizzle mocks during deployment introspection, so the database
335
+ // and Redis below never touch external services.
285
336
  const introspect = process.env.BUNDERSTACK_INTROSPECT === '1'
286
- if (introspect) {
287
- config.database.url = ':memory:'
288
- config.database.authToken = undefined
289
- }
290
337
  const email = createEmail(options.email, { env })
291
338
  // Merge bunderstack's internal tables (file-meta, idempotency) into the
292
339
  // schema used for the db client + provisioning. CRUD/access stay on the USER
293
340
  // schema so internal tables never get a CRUD route.
294
341
  const mergedSchema = withInternalTables(options.schema)
295
- const { db, driver } = await createDb(mergedSchema, {
342
+ const lifecycle = new Lifecycle()
343
+ const {
344
+ db,
345
+ driver,
346
+ close: closeDatabase,
347
+ } = await createDb(mergedSchema, {
296
348
  ...config.database,
297
349
  dialect,
350
+ introspect,
298
351
  })
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
352
+ if (closeDatabase) lifecycle.add(closeDatabase)
353
+ try {
354
+ // `db` is typed with the merged schema (user tables + internal tables) so the
355
+ // storage/idempotency code can query the internal tables. The public surface
356
+ // and CRUD only expose the USER schema. TS can widen the merged-schema db type
357
+ // on its own (storage/auth pass `db` directly), but it can't *narrow* a
358
+ // generic schema view, so this single intentional cast produces the
359
+ // user-facing, per-dialect db type. See `app.db` / crud below.
360
+ const userDb = db as unknown as DbFor<TSchema>
361
+ const auth = createAuth(
362
+ db,
363
+ withEmailAuthDefaults(config.auth, email, Boolean(options.email)),
364
+ dialect,
365
+ )
366
+ // Internal routers consume the narrow AuthSessionResolver contract, not the
367
+ // raw better-auth instance. app.auth still exposes `auth` unchanged.
368
+ const authResolver = toAuthSessionResolver(auth)
369
+ const resolvedAccess = validateAndResolveAccess(
370
+ options.schema,
371
+ options.access,
372
+ )
373
+ const realtimeBufferSize =
374
+ typeof config.realtime === 'object'
375
+ ? config.realtime.bufferSize
376
+ : undefined
377
+ const configuredRedisUrl = config.realtime
322
378
  ? resolveRealtimeRedisUrl(config.realtime, env)
323
379
  : 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,
380
+ const configuredRealtimeTransport: RealtimeTransport = !config.realtime
381
+ ? 'disabled'
382
+ : configuredRedisUrl
383
+ ? 'redis'
384
+ : 'memory'
385
+ const redisUrl = introspect ? undefined : configuredRedisUrl
386
+ const broker = config.realtime
387
+ ? redisUrl
388
+ ? createRedisRealtimeBroker({
389
+ access: resolvedAccess,
390
+ redis: () => {
391
+ // Redis pub/sub requires a dedicated connection (subscribe puts the client into
392
+ // a restricted state). We use one client for commands and a second for subscribe.
393
+ const cmdClient = new Bun.RedisClient(redisUrl)
394
+ const subClient = new Bun.RedisClient(redisUrl)
395
+ return {
396
+ incr: (key: string) => cmdClient.incr(key),
397
+ publish: (channel: string, message: string) =>
398
+ cmdClient.publish(channel, message),
399
+ subscribe: (channel: string, listener: (msg: string) => void) =>
400
+ subClient.subscribe(channel, listener),
401
+ lpush: (key: string, value: string) =>
402
+ cmdClient.lpush(key, value),
403
+ ltrim: (key: string, start: number, stop: number) =>
404
+ cmdClient.ltrim(key, start, stop),
405
+ lrange: (key: string, start: number, stop: number) =>
406
+ cmdClient.lrange(key, start, stop),
407
+ close: () => {
408
+ cmdClient.close()
409
+ subClient.close()
410
+ },
411
+ }
412
+ },
413
+ bufferSize: realtimeBufferSize,
414
+ })
415
+ : createRealtimeBroker({
416
+ access: resolvedAccess,
417
+ bufferSize: realtimeBufferSize,
418
+ })
419
+ : undefined
420
+ const runtimeRealtimeTransport: RealtimeTransport = !broker
421
+ ? 'disabled'
422
+ : redisUrl
423
+ ? 'redis'
424
+ : 'memory'
425
+ const realtime = createRealtimeFacade<TSchema>(
426
+ broker,
427
+ runtimeRealtimeTransport,
428
+ )
429
+ const crudRouter = buildCrudRouter(options.schema, userDb, {
430
+ auth: authResolver,
431
+ access: resolvedAccess,
432
+ idempotency: options.idempotency,
433
+ realtime,
434
+ })
435
+ const realtimeRouter = broker
436
+ ? buildRealtimeRouter(broker, {
437
+ auth: authResolver,
438
+ keepaliveMs:
439
+ typeof config.realtime === 'object'
440
+ ? config.realtime.keepaliveMs
441
+ : undefined,
356
442
  })
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),
443
+ : undefined
444
+ const registry = createBucketStorages(config.storage)
445
+ if (broker) lifecycle.add(() => broker.close())
446
+ const storageRouter = buildBucketStorageRouter({
447
+ registry,
448
+ db,
449
+ auth: authResolver,
438
450
  })
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')
451
+ const storage: StorageFacade = {
452
+ async delete(fileId) {
453
+ const bucketName = fileId.split('/')[0] ?? ''
454
+ const entry = registry.get(bucketName)
455
+ if (entry) {
456
+ await deleteFileWithDerivatives(entry.adapter, db, fileId)
457
+ } else {
458
+ // Unknown bucket: no adapter to clean, but still drop the meta row.
459
+ await deleteFileMetaRow(db, fileId)
460
+ }
461
+ },
462
+ bucket(name) {
463
+ return registry.get(name)?.adapter
464
+ },
465
+ sweep(olderThanMs = DEFAULT_PENDING_TTL_MS) {
466
+ return sweepOrphans(registry, db, olderThanMs)
467
+ },
456
468
  }
457
- const scheduler = startLocalCronScheduler({
458
- cron,
459
- onError: options.onError,
460
- runSlot: async (name, slot) => {
461
- await runCronSlot({
469
+ const jobRunner = jobsDefs
470
+ ? createJobRunner({
462
471
  db,
463
- defs: jobsDefs!,
472
+ defs: jobsDefs,
464
473
  ctx: { db: userDb, env, email, storage, realtime },
465
- name,
466
- slot,
467
- now: Date.now(),
468
474
  })
475
+ : undefined
476
+ const jobs = {
477
+ async enqueue(name: string, input?: unknown, opts?: EnqueueOptions) {
478
+ if (!jobsDefs) {
479
+ throw new Error(
480
+ '[bunderstack] no jobs configured — add a `jobs` key to createBunderstack',
481
+ )
482
+ }
483
+ const result = await enqueueJob(db, jobsDefs, name, input, opts)
484
+ return result
485
+ },
486
+ tick(now?: number) {
487
+ return jobRunner ? jobRunner.tick(now) : Promise.resolve()
469
488
  },
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
489
  }
479
- return scheduler
480
- }
481
- const runWorker = async (
482
- options: AppRunWorkerOptions = {},
483
- ): Promise<void> => {
484
- const handle = await startWorker(options)
485
- try {
490
+ if (jobRunner) jobRunner.setJobsFacade(jobs)
491
+ const startWorker = async (
492
+ options: AppStartWorkerOptions = {},
493
+ ): Promise<WorkerHandle> => {
494
+ if (!jobRunner) {
495
+ throw new Error('[bunderstack] no queue jobs configured')
496
+ }
497
+ if (lifecycle.status !== 'ready') {
498
+ throw new Error('[bunderstack] application lifecycle is closed')
499
+ }
486
500
  const signal = options.signal
487
501
  ? AbortSignal.any([lifecycle.signal, options.signal])
488
502
  : lifecycle.signal
489
- await waitForWorkerShutdown(signal, !options.signal)
490
- } finally {
491
- await handle.close()
492
- await lifecycle.close()
503
+ const handle = startJobWorker({
504
+ ...options,
505
+ signal,
506
+ tick: (now) => jobRunner.tick(now),
507
+ })
508
+ const unregister = lifecycle.add(() => handle.close())
509
+ void handle.closed.finally(unregister)
510
+ return handle
493
511
  }
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,
512
+ const startCronScheduler = async (
513
+ options: AppStartCronSchedulerOptions = {},
514
+ ): Promise<LocalCronScheduler> => {
515
+ const cron = Object.entries(jobsDefs ?? {}).flatMap(
516
+ ([name, definition]) =>
517
+ definition.kind === 'cron'
518
+ ? [{ name, schedule: definition.schedule }]
519
+ : [],
520
+ )
521
+ if (cron.length === 0) {
522
+ throw new Error('[bunderstack] no cron tasks configured')
523
+ }
524
+ if (lifecycle.status !== 'ready') {
525
+ throw new Error('[bunderstack] application lifecycle is closed')
526
+ }
527
+ const scheduler = startLocalCronScheduler({
528
+ cron,
529
+ onError: options.onError,
530
+ runSlot: async (name, slot) => {
531
+ await runCronSlot({
532
+ db,
533
+ defs: jobsDefs!,
534
+ ctx: { db: userDb, env, email, storage, realtime },
535
+ name,
536
+ slot,
537
+ now: Date.now(),
538
+ })
539
+ },
540
+ })
541
+ const unregister = lifecycle.add(() => scheduler.close())
542
+ try {
543
+ await scheduler.tick()
544
+ } catch (error) {
545
+ unregister()
546
+ await scheduler.close()
547
+ throw error
548
+ }
549
+ return scheduler
550
+ }
551
+ const runWorker = async (
552
+ options: AppRunWorkerOptions = {},
553
+ ): Promise<void> => {
554
+ if (
555
+ realtime.transport === 'memory' &&
556
+ !options.allowProcessLocalRealtime
557
+ ) {
558
+ throw new Error(
559
+ '[bunderstack] runWorker() cannot deliver realtime events through the in-memory broker. Configure REDIS_URL or realtime.redis, embed the worker with startWorker(), or pass allowProcessLocalRealtime: true only when jobs never publish realtime.',
560
+ )
561
+ }
562
+ const {
563
+ allowProcessLocalRealtime: _allowProcessLocalRealtime,
564
+ ...workerOptions
565
+ } = options
566
+ const handle = await startWorker(workerOptions)
567
+ try {
568
+ const signal = workerOptions.signal
569
+ ? AbortSignal.any([lifecycle.signal, workerOptions.signal])
570
+ : lifecycle.signal
571
+ await waitForWorkerShutdown(signal, !workerOptions.signal)
572
+ } finally {
573
+ await handle.close()
574
+ await lifecycle.close()
575
+ }
576
+ }
577
+ const trpcRouter: AnyRouter | undefined =
578
+ typeof options.trpc === 'function'
579
+ ? options.trpc(createTRPC<TSchema, ValidatedEnv<TEnv>>())
580
+ : options.trpc
581
+ const trpcHandler = trpcRouter
582
+ ? (req: Request) =>
583
+ fetchRequestHandler({
584
+ endpoint: '/api/trpc',
512
585
  req,
513
- }),
514
- })
515
- : undefined
516
- const cronRouter =
517
- env.BUNDERSTACK_CRON_SECRET
586
+ router: trpcRouter,
587
+ createContext: async () => ({
588
+ db: userDb,
589
+ user: await resolveAccessUser(authResolver, req.headers),
590
+ env,
591
+ email,
592
+ jobs,
593
+ realtime,
594
+ req,
595
+ }),
596
+ })
597
+ : undefined
598
+ const cronRouter = env.BUNDERSTACK_CRON_SECRET
518
599
  ? buildCronRouter({
519
600
  db,
520
601
  defs: jobsDefs ?? {},
@@ -523,69 +604,82 @@ export async function createBunderstack<
523
604
  storage,
524
605
  })
525
606
  : 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
- })
607
+ const { handler, router } = buildHandler({
608
+ crudRouter,
609
+ authHandler: (req) => auth.handler(req),
610
+ storageRouter,
611
+ realtimeRouter,
612
+ trpcHandler,
613
+ cronRouter,
614
+ rateLimit: options.rateLimit,
615
+ })
535
616
 
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,
617
+ const app: BunderstackApp<
618
+ TSchema,
619
+ TAccess,
620
+ BucketNamesOf<TStorage>,
621
+ TEnv,
622
+ AnyRouter | undefined,
623
+ JobsDefs | undefined
624
+ > = {
625
+ handler,
626
+ // Internal tables live on the runtime db but stay out of the public type.
627
+ db: userDb,
628
+ auth,
629
+ storage,
630
+ router,
631
+ env,
632
+ email,
633
+ realtime,
634
+ // Runtime facade is untyped (JobsRuntimeFacade); the generic-typed field
635
+ // narrows `enqueue` per-app from the declared job defs — same relationship
636
+ // as `userDb` above.
637
+ jobs: jobs as never,
638
+ startWorker,
639
+ runWorker,
640
+ startCronScheduler,
641
+ close: () => lifecycle.close(),
642
+ get status() {
643
+ return lifecycle.status
644
+ },
645
+ signal: lifecycle.signal,
646
+ trpcRouter,
647
+ manifest: buildManifest({
648
+ schema: options.schema,
649
+ dialect,
650
+ storage: config.storage,
651
+ envConfig: options.env as EnvConfigInput | undefined,
652
+ realtime: Boolean(config.realtime),
653
+ realtimeTransport: configuredRealtimeTransport,
654
+ jobs: jobsDefs,
655
+ }),
656
+ }
657
+
658
+ // Hidden handle for the optional `bunderstack/provision` entry. Kept off the
659
+ // public type so provisioning stays opt-in (and drizzle-kit out of this
660
+ // module graph).
661
+ ;(app as WithProvisionInternals)[PROVISION_INTERNALS] = {
662
+ db,
663
+ schema: mergedSchema,
664
+ databaseUrl: config.database.url,
665
+ migrationsFolder: config.database.migrations,
568
666
  dialect,
569
- storage: config.storage,
570
- envConfig: options.env as EnvConfigInput | undefined,
571
- realtime: Boolean(config.realtime),
572
- jobs: jobsDefs,
573
- }),
574
- }
667
+ driver,
668
+ adapter: config.database.adapter,
669
+ }
575
670
 
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,
671
+ return app
672
+ } catch (cause) {
673
+ try {
674
+ await lifecycle.close()
675
+ } catch (cleanupCause) {
676
+ throw new AggregateError(
677
+ [cause, cleanupCause],
678
+ '[bunderstack] application initialization failed and cleanup failed',
679
+ )
680
+ }
681
+ throw cause
586
682
  }
587
-
588
- return app
589
683
  }
590
684
 
591
685
  export { MAX_LIST_LIMIT } from './list-query'
@@ -652,6 +746,12 @@ export {
652
746
  asTypeId,
653
747
  } from './typeid'
654
748
  export type { TypeId } from './typeid'
749
+ export type {
750
+ DatabaseAdapter,
751
+ DatabaseConnectOptions,
752
+ DatabaseConnection,
753
+ DatabaseConnectionResult,
754
+ } from './database/adapter'
655
755
  export type { StorageAdapter } from './storage/index'
656
756
  export type {
657
757
  StorageConfigInput,
@@ -662,4 +762,9 @@ export type {
662
762
  export type { TransformSpec } from './storage/thumbnails'
663
763
 
664
764
  export type { RealtimeAction } from './realtime/index'
665
- export type { RealtimeFacade, SchemaTable } from './realtime/facade'
765
+ export { createRealtimeFacade } from './realtime/facade'
766
+ export type {
767
+ RealtimeFacade,
768
+ RealtimeTransport,
769
+ SchemaTable,
770
+ } from './realtime/facade'