bunderstack 0.15.2 → 0.16.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/README.md CHANGED
@@ -87,6 +87,10 @@ import { app } from './bunderstack'
87
87
  await app.runWorker()
88
88
  ```
89
89
 
90
+ Most applications need none of this: background work runs in-process by
91
+ default. Set `BUNDERSTACK_ROLE` to `web` or `worker` to split it across
92
+ processes without changing code.
93
+
90
94
  If a queue handler calls `ctx.realtime.publish()`, the web and worker processes
91
95
  must share a realtime transport. Configure `REDIS_URL` (or
92
96
  `realtime: { redis: "redis://..." }`). `realtime: true` without Redis uses a
@@ -114,13 +118,9 @@ REDIS_URL=redis://localhost:6379 bun src/server.ts
114
118
  REDIS_URL=redis://localhost:6379 bun src/worker.ts
115
119
  ```
116
120
 
117
- Cron tasks (`j.cron()`) are delivered by the host to
118
- `POST /api/_bunderstack/cron/:name`; storage maintenance uses
119
- `POST /api/_bunderstack/maintenance/storage-sweep`. Production requires the
120
- injected `BUNDERSTACK_CRON_SECRET`. Use `await app.startCronScheduler()` only
121
- for local standalone development. `app.manifest.background` tells Bunderhost
122
- whether to deploy an always-on worker (queue jobs) or only HTTP-delivered cron
123
- (cron-only).
121
+ Cron tasks (`j.cron()`) are materialized as job rows keyed by their slot, so
122
+ they run through the same loop, retries, and timeouts as queue jobs. There is
123
+ no separate cron process and no signed dispatch endpoint.
124
124
 
125
125
  ### Publishing custom writes to realtime
126
126
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bunderstack",
3
- "version": "0.15.2",
3
+ "version": "0.16.0",
4
4
  "description": "Batteries-included backend framework for Bun: CRUD APIs, auth, file storage, realtime, tRPC, email, and validated env from a single Drizzle schema and config object.",
5
5
  "keywords": [
6
6
  "backend",
@@ -53,6 +53,7 @@
53
53
  },
54
54
  "scripts": {
55
55
  "test": "bun test",
56
+ "typecheck": "tsc --noEmit",
56
57
  "dev": "bun --hot ../../examples/standalone/server.ts",
57
58
  "db:push": "drizzle-kit push",
58
59
  "db:migrate": "drizzle-kit migrate"
package/src/access.ts CHANGED
@@ -103,6 +103,24 @@ export type ResolvedTableAccess = {
103
103
 
104
104
  export type ResolvedAccess = Map<string, ResolvedTableAccess>
105
105
 
106
+ /**
107
+ * Look up a table's resolved access by its physical table name.
108
+ *
109
+ * `ResolvedAccess` is keyed by schema export name, not table name, so every
110
+ * consumer that starts from a physical name needs this scan. It lives here so
111
+ * CRUD, realtime, and route validation cannot drift apart on which tables they
112
+ * consider enabled.
113
+ */
114
+ export function tableEntryForName(
115
+ access: ResolvedAccess,
116
+ tableName: string,
117
+ ): ResolvedTableAccess | undefined {
118
+ for (const entry of access.values()) {
119
+ if (entry.tableName === tableName) return entry
120
+ }
121
+ return undefined
122
+ }
123
+
106
124
  const DEFAULT_READONLY = [
107
125
  'id',
108
126
  'createdAt',
package/src/config.ts CHANGED
@@ -20,29 +20,10 @@ export type BetterAuthConfig = Omit<
20
20
  'database'
21
21
  >
22
22
 
23
- export const BunderstackOptionsSchema = z.object({
24
- schema: z.record(z.string(), z.unknown()),
25
- access: z.record(z.string(), z.unknown()).optional(),
26
- database: z
27
- .object({
28
- adapter: z.unknown(),
29
- url: z.string().optional(),
30
- authToken: z.string().optional(),
31
- migrations: z.string().optional(),
32
- })
33
- .optional(),
34
- auth: z.record(z.string(), z.unknown()).optional(),
35
- // Loose: bucket access/scope hold functions that can't survive strict zod
36
- // (mirrors how `access` is loose). Resolution happens in resolveBuckets.
37
- storage: z.unknown().optional(),
38
- // Loose: holds zod schemas. Validation happens in validateEnv.
39
- env: z.unknown().optional(),
40
- // Loose: provider may be a function/adapter. Resolution happens in createEmail.
41
- email: z.unknown().optional(),
42
- // Loose: a tRPC router or builder callback. Resolved in createBunderstack.
43
- trpc: z.unknown().optional(),
44
- // Loose: holds handler functions and zod schemas. Resolved in createBunderstack.
45
- jobs: z.unknown().optional(),
23
+ // Only the union-shaped options need runtime validation: they are the ones a
24
+ // JavaScript caller can plausibly get wrong in a way that fails confusingly
25
+ // downstream. Everything else is either typed-only or read raw from `options`.
26
+ const RuntimeOptionsSchema = z.object({
46
27
  rateLimit: z
47
28
  .union([
48
29
  z.boolean(),
@@ -81,19 +62,7 @@ export type BunderstackConfig<
81
62
  | StorageConfigInput
82
63
  | undefined,
83
64
  TEnv extends EnvConfigInput | undefined = EnvConfigInput | undefined,
84
- > = Omit<
85
- z.input<typeof BunderstackOptionsSchema>,
86
- | 'schema'
87
- | 'access'
88
- | 'auth'
89
- | 'authResolver'
90
- | 'storage'
91
- | 'env'
92
- | 'email'
93
- | 'trpc'
94
- | 'jobs'
95
- | 'database'
96
- > & {
65
+ > = {
97
66
  schema: TSchema
98
67
  access?: TAccess
99
68
  database: {
@@ -110,11 +79,20 @@ export type BunderstackConfig<
110
79
  authResolver?: AuthSessionResolver
111
80
  storage?: TStorage
112
81
  env?: TEnv
82
+ /**
83
+ * Stand-in for `process.env`. Feeds both env validation and platform
84
+ * overrides, so tests and embedders have one injection point instead of
85
+ * three.
86
+ */
87
+ processEnv?: Record<string, string | undefined>
88
+ background?: { autoStart?: boolean }
113
89
  email?: EmailConfigInput
114
- // `trpc` is intentionally NOT declared here: createBunderstack intersects
115
- // its own inference-friendly `trpc` declaration (router | builder callback)
116
- // so the callback's `t` parameter gets contextual typing. `jobs` follows the
117
- // same pattern (defs map | builder callback receiving `j`).
90
+ /**
91
+ * Custom Hono routes, mounted at root ahead of bunderstack's own. Declared as
92
+ * a callback because routes in a separate file cannot import the app that is
93
+ * still being constructed the same reason `trpc` takes a builder.
94
+ */
95
+ routes?: (ctx: never) => unknown
118
96
  rateLimit?: boolean | RateLimitConfig
119
97
  idempotency?: boolean | IdempotencyConfig
120
98
  realtime?:
@@ -154,7 +132,7 @@ export function resolveConfig<TSchema extends Record<string, unknown>>(
154
132
  string | undefined
155
133
  >,
156
134
  ): ResolvedConfig {
157
- const parsed = BunderstackOptionsSchema.parse(options)
135
+ const parsed = RuntimeOptionsSchema.parse(options)
158
136
  // Self-validate when the caller didn't pass a pre-validated env, so
159
137
  // resolveConfig stays usable standalone.
160
138
  const resolvedEnv =
@@ -173,14 +151,14 @@ export function resolveConfig<TSchema extends Record<string, unknown>>(
173
151
  adapter,
174
152
  url:
175
153
  platformSource['BUNDERSTACK_DATABASE_URL'] ??
176
- parsed.database?.url ??
154
+ options.database?.url ??
177
155
  resolvedEnv.DATABASE_URL ??
178
156
  defaultUrl,
179
157
  authToken:
180
158
  platformSource['BUNDERSTACK_DATABASE_AUTH_TOKEN'] ??
181
- parsed.database?.authToken ??
159
+ options.database?.authToken ??
182
160
  resolvedEnv.DATABASE_AUTH_TOKEN,
183
- migrations: parsed.database?.migrations ?? './migrations',
161
+ migrations: options.database?.migrations ?? './migrations',
184
162
  },
185
163
  auth: (() => {
186
164
  const authInput = options.auth ?? {}
package/src/cron.ts CHANGED
@@ -1,2 +1,3 @@
1
1
  export { cronMatches, parseCron } from './jobs/cron'
2
- export { signScheduleRequest, verifyScheduleRequest } from './jobs/cron-auth'
2
+ export { floorSlot, slotsDue, CRON_PREFIX, SLOT_MS } from './jobs/slots'
3
+ export type { CatchUp } from './jobs/slots'
package/src/crud.ts CHANGED
@@ -14,6 +14,7 @@ import {
14
14
  type AuthSessionResolver,
15
15
  type CrudOperation,
16
16
  type ResolvedAccess,
17
+ tableEntryForName,
17
18
  type ResolvedTableAccess,
18
19
  type ScopeMap,
19
20
  type ScopeResolver,
@@ -38,15 +39,6 @@ export type CrudRouterOptions<
38
39
  realtime?: RealtimeFacade<TSchema>
39
40
  }
40
41
 
41
- function tableEntryForName(
42
- access: ResolvedAccess,
43
- tableName: string,
44
- ): ResolvedTableAccess | undefined {
45
- for (const entry of access.values()) {
46
- if (entry.tableName === tableName) return entry
47
- }
48
- return undefined
49
- }
50
42
 
51
43
  function isRecord(value: unknown): value is Record<string, unknown> {
52
44
  return value !== null && typeof value === 'object' && !Array.isArray(value)
package/src/env.ts CHANGED
@@ -10,6 +10,10 @@ export type EnvConfigInput = {
10
10
  runtimeEnv?: Record<string, unknown>
11
11
  }
12
12
 
13
+ export type BunderstackRole = 'all' | 'web' | 'worker'
14
+
15
+ const ROLES: readonly BunderstackRole[] = ['all', 'web', 'worker']
16
+
13
17
  /** Vars bunderstack itself consumes, always validated. */
14
18
  export type BaseEnv = {
15
19
  NODE_ENV?: string
@@ -19,7 +23,7 @@ export type BaseEnv = {
19
23
  REDIS_URL?: string
20
24
  RESEND_API_KEY?: string
21
25
  SMTP_URL?: string
22
- BUNDERSTACK_CRON_SECRET?: string
26
+ BUNDERSTACK_ROLE: BunderstackRole
23
27
  }
24
28
 
25
29
  type InferVars<T> =
@@ -53,8 +57,6 @@ export type ValidateEnvOptions = {
53
57
  source?: Record<string, string | undefined>
54
58
  /** Dialect-aware DATABASE_URL fallback; createBunderstack passes it. */
55
59
  defaultDatabaseUrl?: string
56
- /** Require the platform schedule secret for an application with cron work. */
57
- cronConfigured?: boolean
58
60
  }
59
61
 
60
62
  const DEV_AUTH_SECRET = 'dev-secret-change-in-prod'
@@ -109,18 +111,17 @@ export function validateEnv<TEnv extends EnvConfigInput | undefined>(
109
111
  REDIS_URL: source.REDIS_URL,
110
112
  RESEND_API_KEY: source.RESEND_API_KEY,
111
113
  SMTP_URL: source.SMTP_URL,
112
- BUNDERSTACK_CRON_SECRET: source.BUNDERSTACK_CRON_SECRET,
114
+ BUNDERSTACK_ROLE: (source.BUNDERSTACK_ROLE ?? 'all') as BunderstackRole,
113
115
  }
114
116
  if (isProduction && !source.AUTH_SECRET) {
115
117
  issues.push('AUTH_SECRET: required in production')
116
118
  }
117
119
  if (
118
- isProduction &&
119
- options.cronConfigured &&
120
- !source.BUNDERSTACK_CRON_SECRET
120
+ source.BUNDERSTACK_ROLE !== undefined &&
121
+ !ROLES.includes(source.BUNDERSTACK_ROLE as BunderstackRole)
121
122
  ) {
122
123
  issues.push(
123
- 'BUNDERSTACK_CRON_SECRET: required when cron is configured in production',
124
+ `BUNDERSTACK_ROLE: must be one of ${ROLES.join(', ')} (got "${String(source.BUNDERSTACK_ROLE)}")`,
124
125
  )
125
126
  }
126
127
  if (options.emailProvider === 'resend' && !source.RESEND_API_KEY) {
package/src/handler.ts CHANGED
@@ -4,11 +4,11 @@ import { Hono } from 'hono'
4
4
  import { createRateLimiter, type RateLimitConfig } from './rate-limit'
5
5
 
6
6
  interface HandlerParts {
7
+ customRouter?: Hono
7
8
  crudRouter: Hono
8
9
  authHandler?: (req: Request) => Promise<Response>
9
10
  storageRouter?: Hono
10
11
  realtimeRouter?: Hono
11
- cronRouter?: Hono
12
12
  trpcHandler?: (req: Request) => Promise<Response>
13
13
  rateLimit?: boolean | RateLimitConfig
14
14
  }
@@ -20,16 +20,16 @@ export function buildHandler(parts: HandlerParts): {
20
20
  const app = new Hono()
21
21
  const checkRateLimit = createRateLimiter(parts.rateLimit)
22
22
 
23
+ // Registered ahead of everything so custom routes can sit in front of the
24
+ // core app. Collisions are rejected at construction, not silently shadowed.
25
+ if (parts.customRouter) app.route('/', parts.customRouter)
26
+
23
27
  const health = (c: { json: (data: unknown) => Response }) =>
24
28
  c.json({ status: 'ok' })
25
29
  app.get('/health', health)
26
30
  app.get('/api/health', health)
27
31
  app.route('/api', parts.crudRouter)
28
32
 
29
- if (parts.cronRouter) {
30
- app.route('/api/_bunderstack', parts.cronRouter)
31
- }
32
-
33
33
  if (parts.authHandler) {
34
34
  app.all('/api/auth/*', (c) => parts.authHandler!(c.req.raw))
35
35
  }
package/src/index.ts CHANGED
@@ -4,6 +4,8 @@ import type { Hono as HonoType } from 'hono'
4
4
 
5
5
  import { fetchRequestHandler } from '@trpc/server/adapters/fetch'
6
6
 
7
+ import { getTableName, isTable } from 'drizzle-orm'
8
+
7
9
  import type { TableAccessInput } from './access'
8
10
  import type { DbFor } from './db'
9
11
  import type {
@@ -11,15 +13,17 @@ import type {
11
13
  EnqueueOptions,
12
14
  JobsDefs,
13
15
  JobsFacade,
14
- LocalCronScheduler,
15
- LocalCronSchedulerOptions,
16
16
  StartWorkerOptions,
17
17
  WorkerHandle,
18
18
  } from './jobs/index'
19
19
  import type { StorageConfigInput } from './storage/buckets'
20
20
  import type { StorageAdapter } from './storage/index'
21
21
 
22
- import { resolveAccessUser, validateAndResolveAccess } from './access'
22
+ import {
23
+ resolveAccessUser,
24
+ tableEntryForName,
25
+ validateAndResolveAccess,
26
+ } from './access'
23
27
  import {
24
28
  createAuth,
25
29
  toAuthSessionResolver,
@@ -37,10 +41,7 @@ import { withInternalTables } from './internal-tables'
37
41
  import {
38
42
  createJobsBuilder,
39
43
  createJobRunner,
40
- buildCronRouter,
41
44
  enqueueJob,
42
- runCronSlot,
43
- startLocalCronScheduler,
44
45
  startJobWorker,
45
46
  validateJobsDefs,
46
47
  } from './jobs/index'
@@ -57,6 +58,7 @@ import {
57
58
  } from './realtime/facade'
58
59
  import { createRealtimeBroker, buildRealtimeRouter } from './realtime/index'
59
60
  import { createRedisRealtimeBroker } from './realtime/redis'
61
+ import { createRouteContext, validateCustomRoutes } from './routes'
60
62
  import { deleteFileWithDerivatives } from './storage/delete'
61
63
  import { deleteFileMetaRow, insertReadyFile } from './storage/file-meta'
62
64
  import { createBucketStorages } from './storage/registry'
@@ -64,7 +66,8 @@ import { buildBucketStorageRouter } from './storage/router'
64
66
  import { sweepOrphans } from './storage/sweep'
65
67
  import { createTRPC, type BunderstackTRPC } from './trpc'
66
68
 
67
- type AuthInstance = ReturnType<typeof createAuth>
69
+ export type AuthInstance = ReturnType<typeof createAuth>
70
+
68
71
 
69
72
  function waitForWorkerShutdown(
70
73
  signal: AbortSignal,
@@ -138,11 +141,6 @@ export type AppRunWorkerOptions = AppStartWorkerOptions & {
138
141
  */
139
142
  allowProcessLocalRealtime?: boolean
140
143
  }
141
- export type AppStartCronSchedulerOptions = Pick<
142
- LocalCronSchedulerOptions,
143
- 'onError'
144
- >
145
-
146
144
  /** Bucket names declared in a storage config; `string` when unknowable. */
147
145
  export type BucketNamesOf<TStorage> = TStorage extends {
148
146
  buckets: infer B extends Record<string, unknown>
@@ -178,11 +176,9 @@ export type BunderstackApp<
178
176
  startWorker(options?: AppStartWorkerOptions): Promise<WorkerHandle>
179
177
  /** Run a queue worker until aborted, then close the application. */
180
178
  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
179
  close(): Promise<void>
180
+ /** True when this process is running the background tick loop. */
181
+ readonly backgroundRunning: boolean
186
182
  readonly status: LifecycleStatus
187
183
  readonly signal: AbortSignal
188
184
  /** Deploy-time introspection: what this app needs provisioned. */
@@ -345,11 +341,9 @@ export async function createBunderstack<
345
341
  emailProvider: emailProviderTag(options.email),
346
342
  defaultDatabaseUrl:
347
343
  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,
344
+ source: options.processEnv,
351
345
  })
352
- const config = resolveConfig(options, env)
346
+ const config = resolveConfig(options, env, options.processEnv)
353
347
  // Adapters use Drizzle mocks during deployment introspection, so the database
354
348
  // and Redis below never touch external services.
355
349
  const introspect = process.env.BUNDERSTACK_INTROSPECT === '1'
@@ -518,25 +512,41 @@ export async function createBunderstack<
518
512
  })
519
513
  },
520
514
  }
521
- const jobRunner = jobsDefs
515
+ const storageConfigured = Boolean(options.storage)
516
+ // The storage sweep used to be a hardcoded maintenance route. It is an
517
+ // ordinary cron now, so it inherits retries, timeout and onFailed.
518
+ const resolvedDefs: JobsDefs | undefined = storageConfigured
519
+ ? {
520
+ ...(jobsDefs ?? {}),
521
+ 'bunderstack:storage-sweep': {
522
+ kind: 'cron',
523
+ schedule: '0 4 * * *',
524
+ handler: async () => {
525
+ await storage.sweep()
526
+ },
527
+ },
528
+ }
529
+ : jobsDefs
530
+
531
+ const jobRunner = resolvedDefs
522
532
  ? createJobRunner({
523
533
  db,
524
- defs: jobsDefs,
534
+ defs: resolvedDefs,
525
535
  ctx: { db: userDb, env, email, storage, realtime },
526
536
  })
527
537
  : undefined
528
538
  const jobs = {
529
539
  async enqueue(name: string, input?: unknown, opts?: EnqueueOptions) {
530
- if (!jobsDefs) {
540
+ if (!resolvedDefs) {
531
541
  throw new Error(
532
542
  '[bunderstack] no jobs configured — add a `jobs` key to createBunderstack',
533
543
  )
534
544
  }
535
- const result = await enqueueJob(db, jobsDefs, name, input, opts)
545
+ const result = await enqueueJob(db, resolvedDefs, name, input, opts)
536
546
  return result
537
547
  },
538
548
  tick(now?: number) {
539
- return jobRunner ? jobRunner.tick(now) : Promise.resolve()
549
+ return jobRunner ? jobRunner.tick(now) : Promise.resolve({ claimed: 0, ran: 0, failed: 0 })
540
550
  },
541
551
  }
542
552
  if (jobRunner) jobRunner.setJobsFacade(jobs)
@@ -558,54 +568,16 @@ export async function createBunderstack<
558
568
  const handle = startJobWorker({
559
569
  ...options,
560
570
  signal,
561
- tick: (now) => jobRunner.tick(now),
571
+ // The runtime loop only cares that a tick completed; TickResult is for
572
+ // callers that invoke tick() directly.
573
+ tick: async (now) => {
574
+ await jobRunner.tick(now)
575
+ },
562
576
  })
563
577
  const unregister = lifecycle.add(() => handle.close())
564
578
  void handle.closed.finally(unregister)
565
579
  return handle
566
580
  }
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
581
  const runWorker = async (
610
582
  options: AppRunWorkerOptions = {},
611
583
  ): Promise<void> => {
@@ -655,25 +627,52 @@ export async function createBunderstack<
655
627
  }),
656
628
  })
657
629
  : 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,
665
- })
630
+ const customRouter = options.routes
631
+ ? (() => {
632
+ const routeCtx = createRouteContext({
633
+ db: userDb,
634
+ env,
635
+ storage,
636
+ email,
637
+ jobs,
638
+ realtime,
639
+ auth,
640
+ authResolver,
641
+ })
642
+ const built = (
643
+ options.routes as (ctx: unknown) => import('hono').Hono
644
+ )(routeCtx)
645
+ const enabledTables = Object.values(options.schema)
646
+ .filter((table) => isTable(table))
647
+ .map((table) => getTableName(table))
648
+ .filter((name) => tableEntryForName(resolvedAccess, name)?.enabled)
649
+ validateCustomRoutes(built.routes, enabledTables)
650
+ return built
651
+ })()
666
652
  : undefined
667
653
  const { handler, router } = buildHandler({
654
+ customRouter,
668
655
  crudRouter,
669
656
  authHandler: (req) => auth.handler(req),
670
657
  storageRouter,
671
658
  realtimeRouter,
672
659
  trpcHandler,
673
- cronRouter,
674
660
  rateLimit: options.rateLimit,
675
661
  })
676
662
 
663
+ // Topology is a deployment concern: the role decides whether this process
664
+ // runs background work, so application code never has to.
665
+ const roleWantsWorker =
666
+ env.BUNDERSTACK_ROLE === 'all' || env.BUNDERSTACK_ROLE === 'worker'
667
+ const autoStart =
668
+ options.background?.autoStart ??
669
+ (roleWantsWorker && !introspect && resolvedDefs !== undefined)
670
+ let backgroundRunning = false
671
+ if (autoStart) {
672
+ await startWorker()
673
+ backgroundRunning = true
674
+ }
675
+
677
676
  const app: BunderstackApp<
678
677
  TSchema,
679
678
  TAccess,
@@ -697,8 +696,8 @@ export async function createBunderstack<
697
696
  jobs: jobs as never,
698
697
  startWorker,
699
698
  runWorker,
700
- startCronScheduler,
701
699
  close: () => lifecycle.close(),
700
+ backgroundRunning,
702
701
  get status() {
703
702
  return lifecycle.status
704
703
  },
@@ -712,7 +711,7 @@ export async function createBunderstack<
712
711
  envConfig: options.env as EnvConfigInput | undefined,
713
712
  emailProvider: emailProviderTag(options.email),
714
713
  realtime: Boolean(config.realtime),
715
- jobs: jobsDefs,
714
+ jobs: resolvedDefs,
716
715
  }),
717
716
  }
718
717
 
@@ -763,11 +762,7 @@ export type {
763
762
  } from './email'
764
763
  export { createTRPC } from './trpc'
765
764
  export type { BunderstackTRPC, TRPCContext } from './trpc'
766
- export {
767
- createJobsBuilder,
768
- signScheduleRequest,
769
- verifyScheduleRequest,
770
- } from './jobs/index'
765
+ export { createJobsBuilder } from './jobs/index'
771
766
  export type {
772
767
  BunderstackJobContext,
773
768
  BunderstackJobsBuilder,
@@ -783,8 +778,6 @@ export type {
783
778
  JobsRuntimeFacade,
784
779
  QueueJobDefinition,
785
780
  QueueJobKeys,
786
- LocalCronScheduler,
787
- LocalCronSchedulerOptions,
788
781
  RunWorkerOptions,
789
782
  StartWorkerOptions,
790
783
  WorkerHandle,
@@ -831,3 +824,10 @@ export type {
831
824
  RealtimeTransport,
832
825
  SchemaTable,
833
826
  } from './realtime/facade'
827
+
828
+ export type {
829
+ BunderstackRouteContext,
830
+ RouteContext,
831
+ RoutesBuilder,
832
+ } from './routes'
833
+
@@ -67,20 +67,4 @@ export const bunderstackJobsPg = pgTable(
67
67
  ],
68
68
  )
69
69
 
70
- export const bunderstackCronRunsPg = pgTable(
71
- '_bunderstack_cron_runs',
72
- {
73
- taskId: text('task_id').notNull(),
74
- scheduledAt: bigint('scheduled_at', { mode: 'number' }).notNull(),
75
- status: text('status').notNull(),
76
- attempts: integer('attempts').notNull().default(0),
77
- lockedUntil: bigint('locked_until', { mode: 'number' }),
78
- lastError: text('last_error'),
79
- startedAt: bigint('started_at', { mode: 'number' }),
80
- finishedAt: bigint('finished_at', { mode: 'number' }),
81
- },
82
- (t) => [
83
- primaryKey({ columns: [t.taskId, t.scheduledAt] }),
84
- index('bcr_claim').on(t.status, t.lockedUntil),
85
- ],
86
- )
70
+