bunderstack 0.4.0 → 0.6.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
@@ -13,7 +13,7 @@ bun add bunderstack
13
13
  import { createBunderstack } from 'bunderstack'
14
14
  import * as schema from './schema'
15
15
 
16
- const app = createBunderstack({
16
+ const app = await createBunderstack({
17
17
  schema,
18
18
  auth: { emailAndPassword: { enabled: true } },
19
19
  access: {
@@ -57,9 +57,28 @@ don't throw. Then read `app.manifest`:
57
57
  process.env.BUNDERSTACK_INTROSPECT = '1'
58
58
  const { app } = await import('./src/bunderstack')
59
59
  console.log(JSON.stringify(app.manifest))
60
- // { dialect, tables, defaultBucket, buckets, realtime, env: { server, client } }
60
+ // { version: 2, dialect, tables, tableMap, systemTables, background, ... }
61
61
  ```
62
62
 
63
+ ### Background runtime
64
+
65
+ Declaring jobs does not start a worker. Queue jobs (`j.job()`) are processed by
66
+ an explicit worker process:
67
+
68
+ ```ts
69
+ import { app } from './bunderstack'
70
+
71
+ await app.runWorker()
72
+ ```
73
+
74
+ Cron tasks (`j.cron()`) are delivered by the host to
75
+ `POST /api/_bunderstack/cron/:name`; storage maintenance uses
76
+ `POST /api/_bunderstack/maintenance/storage-sweep`. Production requires the
77
+ injected `BUNDERSTACK_CRON_SECRET`. Use `await app.startCronScheduler()` only
78
+ for local standalone development. `app.manifest.background` tells Bunderhost
79
+ whether to deploy an always-on worker (queue jobs) or only HTTP-delivered cron
80
+ (cron-only).
81
+
63
82
  ## Shipping TypeScript source
64
83
 
65
84
  This package publishes raw TypeScript (`exports` point at `.ts` files). Bun
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bunderstack",
3
- "version": "0.4.0",
3
+ "version": "0.6.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",
@@ -39,7 +39,8 @@
39
39
  "./typeid": "./src/typeid.ts",
40
40
  "./typeid/pg": "./src/typeid-pg.ts",
41
41
  "./env": "./src/env.ts",
42
- "./trpc": "./src/trpc.ts"
42
+ "./trpc": "./src/trpc.ts",
43
+ "./cron": "./src/cron.ts"
43
44
  },
44
45
  "scripts": {
45
46
  "test": "bun test",
package/src/access.ts CHANGED
@@ -75,7 +75,10 @@ export type TableAccessInput = {
75
75
  sortableColumns?: string[]
76
76
  /** Default list ordering when `?sort` is omitted. Defaults to `{ column: 'id', order: 'desc' }`. */
77
77
  defaultSort?: DefaultSort
78
- scope?: ScopeResolver
78
+ scope?: {
79
+ read?: ScopeResolver
80
+ write?: ScopeResolver
81
+ }
79
82
  }
80
83
 
81
84
  export type ResolvedTableAccess = {
@@ -94,7 +97,8 @@ export type ResolvedTableAccess = {
94
97
  filterableColumns: string[]
95
98
  sortableColumns: string[]
96
99
  defaultSort: DefaultSort
97
- scope?: ScopeResolver
100
+ readScope?: ScopeResolver
101
+ writeScope?: ScopeResolver
98
102
  }
99
103
 
100
104
  export type ResolvedAccess = Map<string, ResolvedTableAccess>
@@ -200,7 +204,8 @@ function resolveDefaults(
200
204
  ...(ownerColumn ? [ownerColumn] : []),
201
205
  ],
202
206
  searchableColumns: input.searchableColumns,
203
- scope: input.scope,
207
+ readScope: input.scope?.read,
208
+ writeScope: input.scope?.write,
204
209
  ...listAccess,
205
210
  }
206
211
  }
@@ -306,7 +311,7 @@ export function validateAndResolveAccess<
306
311
  columns.includes('userId')
307
312
 
308
313
  if (!hasExplicitRules && !hasConventionOwner) continue
309
- if (!ownerColumn && input?.crud !== true && !input?.scope) continue
314
+ if (!ownerColumn && input?.crud !== true && !input?.scope?.read && !input?.scope?.write) continue
310
315
 
311
316
  if (input?.ownerColumn && !columns.includes(input.ownerColumn)) {
312
317
  throw new Error(
package/src/cron.ts ADDED
@@ -0,0 +1,2 @@
1
+ export { cronMatches, parseCron } from './jobs/cron'
2
+ export { signScheduleRequest, verifyScheduleRequest } from './jobs/cron-auth'
package/src/crud.ts CHANGED
@@ -16,6 +16,8 @@ import {
16
16
  type ResolvedAccess,
17
17
  type ResolvedTableAccess,
18
18
  type ScopeMap,
19
+ type ScopeResolver,
20
+ type AccessContext,
19
21
  } from './access'
20
22
  import { ErrorCode, apiError, ListQueryError } from './errors'
21
23
  import {
@@ -68,14 +70,9 @@ export function buildCrudRouter<TSchema extends Record<string, unknown>>(
68
70
  const idempotency = resolveIdempotencyConfig(options.idempotency)
69
71
 
70
72
  const scopeFor = (
71
- tableAccess: ResolvedTableAccess,
72
- ctx: {
73
- user: AccessUser | null
74
- session: { activeOrganizationId: string | null } | null
75
- request: Request
76
- },
77
- ): ScopeMap | undefined =>
78
- tableAccess.scope ? tableAccess.scope({ ...ctx }) : undefined
73
+ resolver: ScopeResolver | undefined,
74
+ ctx: AccessContext,
75
+ ): ScopeMap | undefined => (resolver ? resolver(ctx) : undefined)
79
76
 
80
77
  for (const table of Object.values(schema)) {
81
78
  if (!isTable(table)) continue
@@ -109,7 +106,7 @@ export function buildCrudRouter<TSchema extends Record<string, unknown>>(
109
106
 
110
107
  try {
111
108
  const params = parseListParams(new URL(c.req.url), tableAccess)
112
- const scope = scopeFor(tableAccess, {
109
+ const scope = scopeFor(tableAccess.readScope, {
113
110
  user,
114
111
  session,
115
112
  request: c.req.raw,
@@ -164,7 +161,7 @@ export function buildCrudRouter<TSchema extends Record<string, unknown>>(
164
161
  )
165
162
  }
166
163
 
167
- const scope = scopeFor(tableAccess, { user, session, request: c.req.raw })
164
+ const scope = scopeFor(tableAccess.readScope, { user, session, request: c.req.raw })
168
165
  if (
169
166
  scope &&
170
167
  !rowMatchesScope(rows[0] as Record<string, unknown>, scope)
@@ -241,7 +238,7 @@ export function buildCrudRouter<TSchema extends Record<string, unknown>>(
241
238
  user?.id ?? null,
242
239
  )
243
240
 
244
- const scope = scopeFor(tableAccess, { user, session, request: c.req.raw })
241
+ const scope = scopeFor(tableAccess.writeScope, { user, session, request: c.req.raw, body: body as Record<string, unknown> })
245
242
  const stamped = scope ? stampScope(values, scope) : values
246
243
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
247
244
  const rows = await (db as any).insert(table).values(stamped).returning()
@@ -280,10 +277,10 @@ export function buildCrudRouter<TSchema extends Record<string, unknown>>(
280
277
  return apiError(c, ErrorCode.NOT_FOUND, 'Not found', 404)
281
278
  }
282
279
 
283
- const scope = scopeFor(tableAccess, { user, session, request: c.req.raw })
280
+ const readScope = scopeFor(tableAccess.readScope, { user, session, request: c.req.raw })
284
281
  if (
285
- scope &&
286
- !rowMatchesScope(existing[0] as Record<string, unknown>, scope)
282
+ readScope &&
283
+ !rowMatchesScope(existing[0] as Record<string, unknown>, readScope)
287
284
  ) {
288
285
  return apiError(c, ErrorCode.NOT_FOUND, 'Not found', 404)
289
286
  }
@@ -320,10 +317,13 @@ export function buildCrudRouter<TSchema extends Record<string, unknown>>(
320
317
  user?.id ?? null,
321
318
  )
322
319
 
320
+ const writeScope = scopeFor(tableAccess.writeScope, { user, session, request: c.req.raw, body: body as Record<string, unknown> })
321
+ const stamped = writeScope ? stampScope(values, writeScope) : values
322
+
323
323
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
324
324
  const rows = await (db as any)
325
325
  .update(table)
326
- .set(values)
326
+ .set(stamped)
327
327
  .where(eq(idCol as any, id))
328
328
  .returning()
329
329
  if (!rows[0]) {
@@ -350,7 +350,7 @@ export function buildCrudRouter<TSchema extends Record<string, unknown>>(
350
350
  return apiError(c, ErrorCode.NOT_FOUND, 'Not found', 404)
351
351
  }
352
352
 
353
- const scope = scopeFor(tableAccess, { user, session, request: c.req.raw })
353
+ const scope = scopeFor(tableAccess.readScope, { user, session, request: c.req.raw })
354
354
  if (
355
355
  scope &&
356
356
  !rowMatchesScope(existing[0] as Record<string, unknown>, scope)
package/src/env.ts CHANGED
@@ -19,6 +19,7 @@ export type BaseEnv = {
19
19
  REDIS_URL?: string
20
20
  RESEND_API_KEY?: string
21
21
  SMTP_URL?: string
22
+ BUNDERSTACK_CRON_SECRET?: string
22
23
  }
23
24
 
24
25
  type InferVars<T> = T extends Record<string, ZodType>
@@ -51,6 +52,8 @@ export type ValidateEnvOptions = {
51
52
  source?: Record<string, string | undefined>
52
53
  /** Dialect-aware DATABASE_URL fallback; createBunderstack passes it. */
53
54
  defaultDatabaseUrl?: string
55
+ /** Require the platform schedule secret for an application with cron work. */
56
+ cronConfigured?: boolean
54
57
  }
55
58
 
56
59
  const DEV_AUTH_SECRET = 'dev-secret-change-in-prod'
@@ -105,10 +108,14 @@ export function validateEnv<TEnv extends EnvConfigInput | undefined>(
105
108
  REDIS_URL: source.REDIS_URL,
106
109
  RESEND_API_KEY: source.RESEND_API_KEY,
107
110
  SMTP_URL: source.SMTP_URL,
111
+ BUNDERSTACK_CRON_SECRET: source.BUNDERSTACK_CRON_SECRET,
108
112
  }
109
113
  if (isProduction && !source.AUTH_SECRET) {
110
114
  issues.push('AUTH_SECRET: required in production')
111
115
  }
116
+ if (isProduction && options.cronConfigured && !source.BUNDERSTACK_CRON_SECRET) {
117
+ issues.push('BUNDERSTACK_CRON_SECRET: required when cron is configured in production')
118
+ }
112
119
  if (options.emailProvider === 'resend' && !source.RESEND_API_KEY) {
113
120
  issues.push("RESEND_API_KEY: required when email provider is 'resend'")
114
121
  }
package/src/handler.ts CHANGED
@@ -8,6 +8,7 @@ interface HandlerParts {
8
8
  authHandler?: (req: Request) => Promise<Response>
9
9
  storageRouter?: Hono
10
10
  realtimeRouter?: Hono
11
+ cronRouter?: Hono
11
12
  trpcHandler?: (req: Request) => Promise<Response>
12
13
  rateLimit?: boolean | RateLimitConfig
13
14
  }
@@ -25,6 +26,10 @@ export function buildHandler(parts: HandlerParts): {
25
26
  app.get('/api/health', health)
26
27
  app.route('/api', parts.crudRouter)
27
28
 
29
+ if (parts.cronRouter) {
30
+ app.route('/api/_bunderstack', parts.cronRouter)
31
+ }
32
+
28
33
  if (parts.authHandler) {
29
34
  app.all('/api/auth/*', (c) => parts.authHandler!(c.req.raw))
30
35
  }
package/src/index.ts CHANGED
@@ -22,10 +22,15 @@ import { buildCrudRouter } from './crud'
22
22
  import { createDb } from './db'
23
23
  import { buildHandler } from './handler'
24
24
  import { withInternalTables } from './internal-tables'
25
+ import { Lifecycle, type LifecycleStatus } from './lifecycle'
25
26
  import {
26
27
  createJobsBuilder,
27
28
  createJobRunner,
29
+ buildCronRouter,
28
30
  enqueueJob,
31
+ runCronSlot,
32
+ startLocalCronScheduler,
33
+ startJobWorker,
29
34
  validateJobsDefs,
30
35
  } from './jobs/index'
31
36
  import type {
@@ -33,6 +38,10 @@ import type {
33
38
  EnqueueOptions,
34
39
  JobsDefs,
35
40
  JobsFacade,
41
+ LocalCronScheduler,
42
+ LocalCronSchedulerOptions,
43
+ StartWorkerOptions,
44
+ WorkerHandle,
36
45
  } from './jobs/index'
37
46
  import {
38
47
  PROVISION_INTERNALS,
@@ -48,13 +57,31 @@ import { sweepOrphans } from './storage/sweep'
48
57
 
49
58
  type AuthInstance = ReturnType<typeof createAuth>
50
59
 
60
+ function waitForWorkerShutdown(
61
+ signal: AbortSignal,
62
+ installProcessListeners: boolean,
63
+ ): Promise<void> {
64
+ if (signal.aborted) return Promise.resolve()
65
+
66
+ return new Promise((resolve) => {
67
+ const done = () => {
68
+ signal.removeEventListener('abort', done)
69
+ if (installProcessListeners) {
70
+ process.removeListener('SIGINT', done)
71
+ process.removeListener('SIGTERM', done)
72
+ }
73
+ resolve()
74
+ }
75
+ signal.addEventListener('abort', done, { once: true })
76
+ if (installProcessListeners) {
77
+ process.once('SIGINT', done)
78
+ process.once('SIGTERM', done)
79
+ }
80
+ })
81
+ }
82
+
51
83
  /** Default age before an unconfirmed `pending` file is treated as an orphan. */
52
84
  const DEFAULT_PENDING_TTL_MS = 30 * 60_000
53
- /** How often the auto-started orphan sweep runs. */
54
- const SWEEP_INTERVAL_MS = 10 * 60_000
55
- /** How often the in-process job worker polls for claimable jobs. */
56
- const JOBS_POLL_INTERVAL_MS = 1000
57
-
58
85
  /**
59
86
  * Public storage facade exposed as `app.storage`. Object-level operations live
60
87
  * on the per-bucket adapters; this surface offers the app-wide deletes that
@@ -67,12 +94,19 @@ export interface StorageFacade {
67
94
  bucket(name: string): StorageAdapter | undefined
68
95
  /**
69
96
  * Reap stale `pending` uploads older than `olderThanMs` (default 30m). Runs
70
- * automatically on an interval; exposed for manual/test invocation. Returns
97
+ * only when explicitly invoked by the host or local scheduler. Returns
71
98
  * the count reaped.
72
99
  */
73
100
  sweep(olderThanMs?: number): Promise<number>
74
101
  }
75
102
 
103
+ export type AppStartWorkerOptions = Omit<StartWorkerOptions, 'tick'>
104
+ export type AppRunWorkerOptions = AppStartWorkerOptions
105
+ export type AppStartCronSchedulerOptions = Pick<
106
+ LocalCronSchedulerOptions,
107
+ 'onError'
108
+ >
109
+
76
110
  /** Bucket names declared in a storage config; `string` when unknowable. */
77
111
  export type BucketNamesOf<TStorage> = TStorage extends {
78
112
  buckets: infer B extends Record<string, unknown>
@@ -102,6 +136,16 @@ export type BunderstackApp<
102
136
  email: EmailFacade
103
137
  /** Job queue facade; always present — enqueue throws when jobs aren't configured. */
104
138
  jobs: JobsFacade<TJobsDefs extends JobsDefs ? TJobsDefs : Record<never, never>>
139
+ startWorker(options?: AppStartWorkerOptions): Promise<WorkerHandle>
140
+ /** Run a queue worker until aborted, then close the application. */
141
+ runWorker(options?: AppRunWorkerOptions): Promise<void>
142
+ /** Start local delivery for declared cron tasks (for development only). */
143
+ startCronScheduler(
144
+ options?: AppStartCronSchedulerOptions,
145
+ ): Promise<LocalCronScheduler>
146
+ close(): Promise<void>
147
+ readonly status: LifecycleStatus
148
+ readonly signal: AbortSignal
105
149
  /** Deploy-time introspection: what this app needs provisioned. */
106
150
  manifest: BunderstackManifest
107
151
  /**
@@ -214,12 +258,21 @@ export async function createBunderstack<
214
258
  >
215
259
  > {
216
260
  const dialect = detectDialect(options.schema)
261
+ const jobsDefs: JobsDefs | undefined = options.jobs
262
+ ? typeof options.jobs === 'function'
263
+ ? options.jobs(createJobsBuilder<TSchema, ValidatedEnv<TEnv>>())
264
+ : (options.jobs as JobsDefs)
265
+ : undefined
266
+ if (jobsDefs) validateJobsDefs(jobsDefs)
217
267
  // Env is validated FIRST: the app refuses to boot on missing/invalid vars,
218
268
  // and everything downstream (config, email, trpc ctx) consumes the result.
219
269
  const env = validateEnv(options.env, {
220
270
  emailProvider: emailProviderTag(options.email),
221
271
  defaultDatabaseUrl:
222
272
  dialect === 'pg' ? 'file:./data.pglite' : 'file:./data.db',
273
+ // Storage orphan cleanup is also a signed platform schedule, so every
274
+ // production app has scheduled delivery even without user-defined cron.
275
+ cronConfigured: true,
223
276
  })
224
277
  const config = resolveConfig(options, env)
225
278
  // Introspection mode (BUNDERSTACK_INTROSPECT=1): deployment platforms import
@@ -269,7 +322,7 @@ export async function createBunderstack<
269
322
  ? redisUrl
270
323
  ? createRedisRealtimeBroker({
271
324
  access: resolvedAccess,
272
- redis: (() => {
325
+ redis: () => {
273
326
  // Redis pub/sub requires a dedicated connection (subscribe puts the client into
274
327
  // a restricted state). We use one client for commands and a second for subscribe.
275
328
  const cmdClient = new Bun.RedisClient(redisUrl)
@@ -286,8 +339,12 @@ export async function createBunderstack<
286
339
  cmdClient.ltrim(key, start, stop),
287
340
  lrange: (key: string, start: number, stop: number) =>
288
341
  cmdClient.lrange(key, start, stop),
342
+ close: () => {
343
+ cmdClient.close()
344
+ subClient.close()
345
+ },
289
346
  }
290
- })(),
347
+ },
291
348
  bufferSize: realtimeBufferSize,
292
349
  })
293
350
  : createRealtimeBroker({
@@ -311,6 +368,8 @@ export async function createBunderstack<
311
368
  })
312
369
  : undefined
313
370
  const registry = createBucketStorages(config.storage)
371
+ const lifecycle = new Lifecycle()
372
+ if (broker) lifecycle.add(() => broker.close())
314
373
  const storageRouter = buildBucketStorageRouter({
315
374
  registry,
316
375
  db,
@@ -334,18 +393,6 @@ export async function createBunderstack<
334
393
  return sweepOrphans(registry, db, olderThanMs)
335
394
  },
336
395
  }
337
- // Auto-reap orphaned `pending` uploads. `unref()` keeps this from holding the
338
- // process (and test runners) open.
339
- const sweepTimer = setInterval(() => {
340
- void sweepOrphans(registry, db, DEFAULT_PENDING_TTL_MS).catch(() => {})
341
- }, SWEEP_INTERVAL_MS)
342
- sweepTimer.unref?.()
343
- const jobsDefs: JobsDefs | undefined = options.jobs
344
- ? typeof options.jobs === 'function'
345
- ? options.jobs(createJobsBuilder<TSchema, ValidatedEnv<TEnv>>())
346
- : (options.jobs as JobsDefs)
347
- : undefined
348
- if (jobsDefs) validateJobsDefs(jobsDefs)
349
396
  const jobRunner = jobsDefs
350
397
  ? createJobRunner({
351
398
  db,
@@ -361,8 +408,6 @@ export async function createBunderstack<
361
408
  )
362
409
  }
363
410
  const result = await enqueueJob(db, jobsDefs, name, input, opts)
364
- // Wake the local worker so same-process jobs start without poll latency.
365
- if (jobRunner && !introspect) void jobRunner.tick().catch(() => {})
366
411
  return result
367
412
  },
368
413
  tick(now?: number) {
@@ -370,11 +415,78 @@ export async function createBunderstack<
370
415
  },
371
416
  }
372
417
  if (jobRunner) jobRunner.setJobsFacade(jobs)
373
- if (jobRunner && !introspect) {
374
- const jobsTimer = setInterval(() => {
375
- void jobRunner.tick().catch(() => {})
376
- }, JOBS_POLL_INTERVAL_MS)
377
- jobsTimer.unref?.()
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),
434
+ })
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')
452
+ }
453
+ const scheduler = startLocalCronScheduler({
454
+ cron,
455
+ onError: options.onError,
456
+ runSlot: async (name, slot) => {
457
+ await runCronSlot({
458
+ db,
459
+ defs: jobsDefs!,
460
+ ctx: { db: userDb, env, email, storage },
461
+ name,
462
+ slot,
463
+ now: Date.now(),
464
+ })
465
+ },
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
+ }
475
+ return scheduler
476
+ }
477
+ const runWorker = async (
478
+ options: AppRunWorkerOptions = {},
479
+ ): Promise<void> => {
480
+ const handle = await startWorker(options)
481
+ try {
482
+ const signal = options.signal
483
+ ? AbortSignal.any([lifecycle.signal, options.signal])
484
+ : lifecycle.signal
485
+ await waitForWorkerShutdown(signal, !options.signal)
486
+ } finally {
487
+ await handle.close()
488
+ await lifecycle.close()
489
+ }
378
490
  }
379
491
  const trpcRouter: AnyRouter | undefined =
380
492
  typeof options.trpc === 'function'
@@ -396,12 +508,23 @@ export async function createBunderstack<
396
508
  }),
397
509
  })
398
510
  : undefined
511
+ const cronRouter =
512
+ env.BUNDERSTACK_CRON_SECRET
513
+ ? buildCronRouter({
514
+ db,
515
+ defs: jobsDefs ?? {},
516
+ ctx: { db: userDb, env, email, storage },
517
+ secret: env.BUNDERSTACK_CRON_SECRET,
518
+ storage,
519
+ })
520
+ : undefined
399
521
  const { handler, router } = buildHandler({
400
522
  crudRouter,
401
523
  authHandler: (req) => auth.handler(req),
402
524
  storageRouter,
403
525
  realtimeRouter,
404
526
  trpcHandler,
527
+ cronRouter,
405
528
  rateLimit: options.rateLimit,
406
529
  })
407
530
 
@@ -425,6 +548,14 @@ export async function createBunderstack<
425
548
  // narrows `enqueue` per-app from the declared job defs — same relationship
426
549
  // as `userDb` above.
427
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,
428
559
  trpcRouter,
429
560
  manifest: buildManifest({
430
561
  schema: options.schema,
@@ -461,7 +592,7 @@ export type {
461
592
  export { validateEnv, createClientEnv, BunderstackEnvError } from './env'
462
593
  export type { EnvConfigInput, BaseEnv, ValidatedEnv } from './env'
463
594
  export { buildManifest } from './manifest'
464
- export type { BunderstackManifest, ManifestEnvVar, ManifestJob } from './manifest'
595
+ export type { BunderstackManifest, ManifestEnvVar } from './manifest'
465
596
  export { createEmail } from './email'
466
597
  export type {
467
598
  EmailMessage,
@@ -471,15 +602,30 @@ export type {
471
602
  } from './email'
472
603
  export { createTRPC } from './trpc'
473
604
  export type { BunderstackTRPC, TRPCContext } from './trpc'
474
- export { createJobsBuilder } from './jobs/index'
605
+ export {
606
+ createJobsBuilder,
607
+ signScheduleRequest,
608
+ verifyScheduleRequest,
609
+ } from './jobs/index'
475
610
  export type {
476
611
  BunderstackJobsBuilder,
612
+ BackgroundDefinition,
613
+ BackgroundDefs,
614
+ CronDefinition,
615
+ CronInvocation,
477
616
  EnqueueOptions,
478
617
  JobContext,
479
618
  JobDefinition,
480
619
  JobsDefs,
481
620
  JobsFacade,
482
621
  JobsRuntimeFacade,
622
+ QueueJobDefinition,
623
+ QueueJobKeys,
624
+ LocalCronScheduler,
625
+ LocalCronSchedulerOptions,
626
+ RunWorkerOptions,
627
+ StartWorkerOptions,
628
+ WorkerHandle,
483
629
  } from './jobs/index'
484
630
  export {
485
631
  defineAccess,
@@ -66,3 +66,21 @@ export const bunderstackJobsPg = pgTable(
66
66
  uniqueIndex('bjq_dedupe').on(t.type, t.dedupeKey),
67
67
  ],
68
68
  )
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
+ )
@@ -12,6 +12,7 @@ import {
12
12
  import { detectDialect } from './dialect'
13
13
  import {
14
14
  bunderstackFilesPg,
15
+ bunderstackCronRunsPg,
15
16
  bunderstackIdempotencyPg,
16
17
  bunderstackJobsPg,
17
18
  } from './internal-tables-pg'
@@ -73,22 +74,43 @@ export const bunderstackJobs = sqliteTable(
73
74
  ],
74
75
  )
75
76
 
77
+ export const bunderstackCronRuns = sqliteTable(
78
+ '_bunderstack_cron_runs',
79
+ {
80
+ taskId: text('task_id').notNull(),
81
+ scheduledAt: integer('scheduled_at').notNull(),
82
+ status: text('status').notNull(),
83
+ attempts: integer('attempts').notNull().default(0),
84
+ lockedUntil: integer('locked_until'),
85
+ lastError: text('last_error'),
86
+ startedAt: integer('started_at'),
87
+ finishedAt: integer('finished_at'),
88
+ },
89
+ (t) => [
90
+ primaryKey({ columns: [t.taskId, t.scheduledAt] }),
91
+ index('bcr_claim').on(t.status, t.lockedUntil),
92
+ ],
93
+ )
94
+
76
95
  export const INTERNAL_TABLES = {
77
96
  bunderstackFiles,
78
97
  bunderstackIdempotency,
79
98
  bunderstackJobs,
99
+ bunderstackCronRuns,
80
100
  } as const
81
101
 
82
102
  export const INTERNAL_TABLE_NAMES: ReadonlySet<string> = new Set([
83
103
  'bunderstack_file_meta',
84
104
  '_bunderstack_idempotency',
85
105
  '_bunderstack_jobs',
106
+ '_bunderstack_cron_runs',
86
107
  ])
87
108
 
88
109
  export const INTERNAL_TABLES_PG = {
89
110
  bunderstackFiles: bunderstackFilesPg,
90
111
  bunderstackIdempotency: bunderstackIdempotencyPg,
91
112
  bunderstackJobs: bunderstackJobsPg,
113
+ bunderstackCronRuns: bunderstackCronRunsPg,
92
114
  } as const
93
115
 
94
116
  // Both dialect twins count as "ours" for the re-export identity check.
@@ -99,6 +121,7 @@ const INTERNAL_TABLE_CANDIDATES = new Map<string, readonly unknown[]>([
99
121
  [bunderstackIdempotency, bunderstackIdempotencyPg],
100
122
  ],
101
123
  [getTableName(bunderstackJobs), [bunderstackJobs, bunderstackJobsPg]],
124
+ [getTableName(bunderstackCronRuns), [bunderstackCronRuns, bunderstackCronRunsPg]],
102
125
  ])
103
126
 
104
127
  /** Internal file-meta table matching the db's dialect. */
@@ -116,6 +139,11 @@ export function jobsTableFor(db: unknown) {
116
139
  return is(db, PgDatabase) ? bunderstackJobsPg : bunderstackJobs
117
140
  }
118
141
 
142
+ /** Internal cron-run table matching the db's dialect. */
143
+ export function cronRunsTableFor(db: unknown) {
144
+ return is(db, PgDatabase) ? bunderstackCronRunsPg : bunderstackCronRuns
145
+ }
146
+
119
147
  export function withInternalTables<TSchema extends Record<string, unknown>>(
120
148
  schema: TSchema,
121
149
  ): TSchema & typeof INTERNAL_TABLES {