bunderstack 0.15.1 → 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.1",
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',
@@ -1,4 +1,11 @@
1
- import { mkdir, readFile, realpath, rename, rm, writeFile } from 'node:fs/promises'
1
+ import {
2
+ mkdir,
3
+ readFile,
4
+ realpath,
5
+ rename,
6
+ rm,
7
+ writeFile,
8
+ } from 'node:fs/promises'
2
9
  import { dirname, isAbsolute, join, relative, resolve } from 'node:path'
3
10
  import { pathToFileURL } from 'node:url'
4
11
 
@@ -25,7 +32,9 @@ export type GenerateBlueprintResult = {
25
32
 
26
33
  export class BlueprintCheckError extends Error {
27
34
  constructor() {
28
- super('bunderstack.blueprint.yaml is missing or stale; run `bunderstack blueprint`')
35
+ super(
36
+ 'bunderstack.blueprint.yaml is missing or stale; run `bunderstack blueprint`',
37
+ )
29
38
  this.name = 'BlueprintCheckError'
30
39
  }
31
40
  }
@@ -44,7 +53,9 @@ function requireRelativePath(value: string, label: string): string {
44
53
  isAbsolute(normalized) ||
45
54
  normalized.split('/').some((part) => !part || part === '..')
46
55
  ) {
47
- throw new Error(`[bunderstack] ${label} must be a relative path without traversal`)
56
+ throw new Error(
57
+ `[bunderstack] ${label} must be a relative path without traversal`,
58
+ )
48
59
  }
49
60
  return normalized
50
61
  }
@@ -52,27 +63,46 @@ function requireRelativePath(value: string, label: string): string {
52
63
  function resolveWithin(root: string, value: string, label: string): string {
53
64
  const path = resolve(root, requireRelativePath(value, label))
54
65
  const pathFromRoot = relative(root, path)
55
- if (pathFromRoot === '..' || pathFromRoot.startsWith('../') || isAbsolute(pathFromRoot)) {
56
- throw new Error(`[bunderstack] ${label} must stay within the application directory`)
66
+ if (
67
+ pathFromRoot === '..' ||
68
+ pathFromRoot.startsWith('../') ||
69
+ isAbsolute(pathFromRoot)
70
+ ) {
71
+ throw new Error(
72
+ `[bunderstack] ${label} must stay within the application directory`,
73
+ )
57
74
  }
58
75
  return path
59
76
  }
60
77
 
61
- function normalizeProjectPath(root: string, value: string, label: string): string {
78
+ function normalizeProjectPath(
79
+ root: string,
80
+ value: string,
81
+ label: string,
82
+ ): string {
62
83
  if (!isAbsolute(value)) return requireRelativePath(value, label)
63
84
  const pathFromRoot = relative(root, resolve(value)) || '.'
64
85
  return requireRelativePath(pathFromRoot, label)
65
86
  }
66
87
 
67
- function requireScript(pkg: AppPackage, name: 'build' | 'start' | 'worker', required: boolean): boolean {
88
+ function requireScript(
89
+ pkg: AppPackage,
90
+ name: 'build' | 'start' | 'worker',
91
+ required: boolean,
92
+ ): boolean {
68
93
  const value = pkg.scripts?.[name]
69
94
  if (typeof value === 'string' && value.trim()) return true
70
- if (required) throw new Error(`[bunderstack] package.json requires a non-empty "${name}" script`)
95
+ if (required)
96
+ throw new Error(
97
+ `[bunderstack] package.json requires a non-empty "${name}" script`,
98
+ )
71
99
  return false
72
100
  }
73
101
 
74
102
  async function packageVersion(): Promise<string> {
75
- const pkg = (await Bun.file(new URL('../package.json', import.meta.url)).json()) as { version: string }
103
+ const pkg = (await Bun.file(
104
+ new URL('../package.json', import.meta.url),
105
+ ).json()) as { version: string }
76
106
  return pkg.version
77
107
  }
78
108
 
@@ -84,28 +114,38 @@ export async function generateBlueprint(
84
114
  const pkg = JSON.parse(await readFile(packagePath, 'utf8')) as AppPackage
85
115
  const allDependencies = { ...pkg.dependencies, ...pkg.devDependencies }
86
116
  if (typeof allDependencies['@tanstack/react-start'] !== 'string') {
87
- throw new Error('[bunderstack] package.json must depend on @tanstack/react-start')
117
+ throw new Error(
118
+ '[bunderstack] package.json must depend on @tanstack/react-start',
119
+ )
88
120
  }
89
121
  requireScript(pkg, 'build', true)
90
122
  requireScript(pkg, 'start', true)
91
123
 
92
124
  const configuredEntry = pkg.bunderstack?.entry
93
125
  const entry = requireRelativePath(
94
- options.entry ?? (typeof configuredEntry === 'string' ? configuredEntry : 'src/bunderstack.ts'),
126
+ options.entry ??
127
+ (typeof configuredEntry === 'string'
128
+ ? configuredEntry
129
+ : 'src/bunderstack.ts'),
95
130
  'entry',
96
131
  )
97
132
  const entryPath = resolveWithin(directory, entry, 'entry')
98
133
  if (!(await Bun.file(entryPath).exists())) {
99
134
  throw new Error(`[bunderstack] entry does not exist: ${entry}`)
100
135
  }
101
- const output = requireRelativePath(options.output ?? 'bunderstack.blueprint.yaml', 'output')
136
+ const output = requireRelativePath(
137
+ options.output ?? 'bunderstack.blueprint.yaml',
138
+ 'output',
139
+ )
102
140
  const outputPath = resolveWithin(directory, output, 'output')
103
141
 
104
142
  const previousIntrospection = process.env.BUNDERSTACK_INTROSPECT
105
143
  process.env.BUNDERSTACK_INTROSPECT = '1'
106
144
  let app: { manifest?: unknown; close?: () => Promise<void> } | undefined
107
145
  try {
108
- const module = (await import(`${pathToFileURL(entryPath).href}?blueprint=${Date.now()}`)) as {
146
+ const module = (await import(
147
+ `${pathToFileURL(entryPath).href}?blueprint=${Date.now()}`
148
+ )) as {
109
149
  app?: typeof app
110
150
  }
111
151
  app = module.app
@@ -123,7 +163,9 @@ export async function generateBlueprint(
123
163
  'meta',
124
164
  '_journal.json',
125
165
  )
126
- const migrationMode = (await Bun.file(migrationJournal).exists()) ? 'migrations' : 'push'
166
+ const migrationMode = (await Bun.file(migrationJournal).exists())
167
+ ? 'migrations'
168
+ : 'push'
127
169
  const blueprint = blueprintFromManifest({
128
170
  manifest: {
129
171
  ...manifest,
@@ -134,12 +176,15 @@ export async function generateBlueprint(
134
176
  migrationMode,
135
177
  })
136
178
  const source = serializeBlueprint(blueprint)
137
- const existing = (await Bun.file(outputPath).exists()) ? await Bun.file(outputPath).text() : undefined
179
+ const existing = (await Bun.file(outputPath).exists())
180
+ ? await Bun.file(outputPath).text()
181
+ : undefined
138
182
  if (options.check) {
139
183
  if (existing !== source) throw new BlueprintCheckError()
140
184
  return { path: outputPath, blueprint, source, changed: false }
141
185
  }
142
- if (existing === source) return { path: outputPath, blueprint, source, changed: false }
186
+ if (existing === source)
187
+ return { path: outputPath, blueprint, source, changed: false }
143
188
  await mkdir(dirname(outputPath), { recursive: true })
144
189
  const temporary = `${outputPath}.${process.pid}.${Date.now()}.tmp`
145
190
  try {
@@ -151,7 +196,8 @@ export async function generateBlueprint(
151
196
  return { path: outputPath, blueprint, source, changed: true }
152
197
  } finally {
153
198
  await app?.close?.()
154
- if (previousIntrospection === undefined) delete process.env.BUNDERSTACK_INTROSPECT
199
+ if (previousIntrospection === undefined)
200
+ delete process.env.BUNDERSTACK_INTROSPECT
155
201
  else process.env.BUNDERSTACK_INTROSPECT = previousIntrospection
156
202
  }
157
203
  }
package/src/blueprint.ts CHANGED
@@ -1,9 +1,10 @@
1
1
  import { parse, stringify } from 'yaml'
2
2
  import { z } from 'zod'
3
3
 
4
- import { parseCron } from './jobs/cron'
5
4
  import type { BunderstackManifest } from './manifest'
6
5
 
6
+ import { parseCron } from './jobs/cron'
7
+
7
8
  export type MigrationMode = 'migrations' | 'push'
8
9
 
9
10
  export type BunderstackBlueprint = {
@@ -48,12 +49,18 @@ const cronSchedule = nonEmpty.refine(
48
49
  const blueprintSchema = z
49
50
  .object({
50
51
  version: z.literal(1),
51
- generator: z.object({ name: z.literal('bunderstack'), version: nonEmpty }).strict(),
52
+ generator: z
53
+ .object({ name: z.literal('bunderstack'), version: nonEmpty })
54
+ .strict(),
52
55
  application: z
53
56
  .object({
54
57
  framework: z.literal('tanstack-start'),
55
58
  scripts: z
56
- .object({ build: z.literal('build'), start: z.literal('start'), worker: z.literal('worker').optional() })
59
+ .object({
60
+ build: z.literal('build'),
61
+ start: z.literal('start'),
62
+ worker: z.literal('worker').optional(),
63
+ })
57
64
  .strict(),
58
65
  })
59
66
  .strict(),
@@ -68,29 +75,65 @@ const blueprintSchema = z
68
75
  migrationsDirectory: relativePath,
69
76
  migrationMode: z.enum(['migrations', 'push']),
70
77
  tables: z.array(
71
- z.object({ exportName: nonEmpty, physicalName: nonEmpty, system: z.boolean() }).strict(),
78
+ z
79
+ .object({
80
+ exportName: nonEmpty,
81
+ physicalName: nonEmpty,
82
+ system: z.boolean(),
83
+ })
84
+ .strict(),
72
85
  ),
73
86
  })
74
87
  .strict(),
75
88
  storage: z
76
89
  .object({
77
90
  defaultBucket: nonEmpty,
78
- buckets: z.array(z.object({ name: nonEmpty, visibility: z.enum(['public', 'private']) }).strict()),
91
+ buckets: z.array(
92
+ z
93
+ .object({
94
+ name: nonEmpty,
95
+ visibility: z.enum(['public', 'private']),
96
+ })
97
+ .strict(),
98
+ ),
79
99
  })
80
100
  .strict(),
81
- realtime: z.object({ required: z.literal(true) }).strict().optional(),
101
+ realtime: z
102
+ .object({ required: z.literal(true) })
103
+ .strict()
104
+ .optional(),
82
105
  })
83
106
  .strict(),
84
107
  environment: z.array(
85
- z.object({ key: nonEmpty, required: z.boolean(), scope: z.enum(['server', 'client']) }).strict(),
108
+ z
109
+ .object({
110
+ key: nonEmpty,
111
+ required: z.boolean(),
112
+ scope: z.enum(['server', 'client']),
113
+ })
114
+ .strict(),
86
115
  ),
87
116
  background: z
88
117
  .object({
89
118
  worker: z.object({ required: z.boolean() }).strict(),
90
119
  jobs: z.array(z.object({ name: nonEmpty }).strict()),
91
- cron: z.array(z.object({ name: nonEmpty, schedule: cronSchedule, timezone: z.literal('UTC') }).strict()),
120
+ cron: z.array(
121
+ z
122
+ .object({
123
+ name: nonEmpty,
124
+ schedule: cronSchedule,
125
+ timezone: z.literal('UTC'),
126
+ })
127
+ .strict(),
128
+ ),
92
129
  maintenance: z.array(
93
- z.object({ name: z.literal('storage-sweep'), schedule: cronSchedule, timezone: z.literal('UTC') }).strict(),
130
+ z
131
+ .object({
132
+ name: z.literal('storage-sweep'),
133
+ schedule: cronSchedule,
134
+ timezone: z.literal('UTC'),
135
+ })
136
+ .strict(),
94
137
  ),
95
138
  })
96
139
  .strict(),
@@ -104,29 +147,61 @@ function sortBy<T>(entries: readonly T[], key: (entry: T) => string): T[] {
104
147
  function rejectDuplicates(collection: string, values: readonly string[]): void {
105
148
  const seen = new Set<string>()
106
149
  for (const value of values) {
107
- if (seen.has(value)) throw new Error(`[bunderstack] duplicate ${collection} "${value}"`)
150
+ if (seen.has(value))
151
+ throw new Error(`[bunderstack] duplicate ${collection} "${value}"`)
108
152
  seen.add(value)
109
153
  }
110
154
  }
111
155
 
112
156
  export function parseBlueprint(value: unknown): BunderstackBlueprint {
113
157
  const blueprint = blueprintSchema.parse(value) as BunderstackBlueprint
114
- rejectDuplicates('database physical table', blueprint.resources.database.tables.map((entry) => entry.physicalName))
115
- rejectDuplicates('database export table', blueprint.resources.database.tables.map((entry) => entry.exportName))
116
- rejectDuplicates('storage bucket', blueprint.resources.storage.buckets.map((entry) => entry.name))
117
- rejectDuplicates('environment key', blueprint.environment.map((entry) => entry.key))
118
- rejectDuplicates('background job', blueprint.background.jobs.map((entry) => entry.name))
119
- rejectDuplicates('background cron', blueprint.background.cron.map((entry) => entry.name))
120
- rejectDuplicates('background maintenance', blueprint.background.maintenance.map((entry) => entry.name))
121
- if (!blueprint.resources.storage.buckets.some((bucket) => bucket.name === blueprint.resources.storage.defaultBucket)) {
122
- throw new Error('[bunderstack] storage defaultBucket must be declared in storage buckets')
158
+ rejectDuplicates(
159
+ 'database physical table',
160
+ blueprint.resources.database.tables.map((entry) => entry.physicalName),
161
+ )
162
+ rejectDuplicates(
163
+ 'database export table',
164
+ blueprint.resources.database.tables.map((entry) => entry.exportName),
165
+ )
166
+ rejectDuplicates(
167
+ 'storage bucket',
168
+ blueprint.resources.storage.buckets.map((entry) => entry.name),
169
+ )
170
+ rejectDuplicates(
171
+ 'environment key',
172
+ blueprint.environment.map((entry) => entry.key),
173
+ )
174
+ rejectDuplicates(
175
+ 'background job',
176
+ blueprint.background.jobs.map((entry) => entry.name),
177
+ )
178
+ rejectDuplicates(
179
+ 'background cron',
180
+ blueprint.background.cron.map((entry) => entry.name),
181
+ )
182
+ rejectDuplicates(
183
+ 'background maintenance',
184
+ blueprint.background.maintenance.map((entry) => entry.name),
185
+ )
186
+ if (
187
+ !blueprint.resources.storage.buckets.some(
188
+ (bucket) => bucket.name === blueprint.resources.storage.defaultBucket,
189
+ )
190
+ ) {
191
+ throw new Error(
192
+ '[bunderstack] storage defaultBucket must be declared in storage buckets',
193
+ )
123
194
  }
124
195
  const workerRequired = blueprint.background.jobs.length > 0
125
196
  if (blueprint.background.worker.required !== workerRequired) {
126
- throw new Error('[bunderstack] background worker.required must match declared queue jobs')
197
+ throw new Error(
198
+ '[bunderstack] background worker.required must match declared queue jobs',
199
+ )
127
200
  }
128
201
  if (Boolean(blueprint.application.scripts.worker) !== workerRequired) {
129
- throw new Error('[bunderstack] application worker script must match declared queue jobs')
202
+ throw new Error(
203
+ '[bunderstack] application worker script must match declared queue jobs',
204
+ )
130
205
  }
131
206
  return blueprint
132
207
  }
@@ -143,27 +218,39 @@ export function blueprintFromManifest(args: {
143
218
  generator: { name: 'bunderstack', version: args.generatorVersion },
144
219
  application: {
145
220
  framework: 'tanstack-start',
146
- scripts: { build: 'build', start: 'start', ...(workerRequired ? { worker: 'worker' } : {}) },
221
+ scripts: {
222
+ build: 'build',
223
+ start: 'start',
224
+ ...(workerRequired ? { worker: 'worker' } : {}),
225
+ },
147
226
  },
148
227
  bunderstack: { entry: args.entry, manifestVersion: 3 },
149
228
  resources: {
150
229
  database: {
151
230
  ...args.manifest.database,
152
231
  migrationMode: args.migrationMode,
153
- tables: sortBy(args.manifest.database.tables, (entry) => entry.physicalName),
232
+ tables: sortBy(
233
+ args.manifest.database.tables,
234
+ (entry) => entry.physicalName,
235
+ ),
154
236
  },
155
237
  storage: {
156
238
  ...args.manifest.storage,
157
239
  buckets: sortBy(args.manifest.storage.buckets, (entry) => entry.name),
158
240
  },
159
- ...(args.manifest.realtime.required ? { realtime: { required: true } } : {}),
241
+ ...(args.manifest.realtime.required
242
+ ? { realtime: { required: true } }
243
+ : {}),
160
244
  },
161
245
  environment: sortBy(args.manifest.environment, (entry) => entry.key),
162
246
  background: {
163
247
  worker: { required: workerRequired },
164
248
  jobs: sortBy(args.manifest.background.jobs, (entry) => entry.name),
165
249
  cron: sortBy(args.manifest.background.cron, (entry) => entry.name),
166
- maintenance: sortBy(args.manifest.background.maintenance, (entry) => entry.name),
250
+ maintenance: sortBy(
251
+ args.manifest.background.maintenance,
252
+ (entry) => entry.name,
253
+ ),
167
254
  },
168
255
  })
169
256
  }
@@ -180,9 +267,15 @@ export function serializeBlueprint(value: BunderstackBlueprint): string {
180
267
  defaultStringType: 'PLAIN',
181
268
  lineWidth: 0,
182
269
  } as const
183
- const source = stringify(parseBlueprintYaml(stringify(blueprint, options)), options)
184
- return source.replace(/^(\s*schedule: )(.+)$/gm, (_match, prefix: string, value: string) => {
185
- const schedule = value.startsWith('"') ? JSON.parse(value) : value
186
- return `${prefix}${JSON.stringify(schedule)}`
187
- })
270
+ const source = stringify(
271
+ parseBlueprintYaml(stringify(blueprint, options)),
272
+ options,
273
+ )
274
+ return source.replace(
275
+ /^(\s*schedule: )(.+)$/gm,
276
+ (_match, prefix: string, value: string) => {
277
+ const schedule = value.startsWith('"') ? JSON.parse(value) : value
278
+ return `${prefix}${JSON.stringify(schedule)}`
279
+ },
280
+ )
188
281
  }
package/src/cli.ts CHANGED
@@ -25,11 +25,19 @@ export async function runCli(
25
25
  return 0
26
26
  }
27
27
  if (args[0] === '--version') {
28
- io.stdout((await Bun.file(new URL('../package.json', import.meta.url)).json() as { version: string }).version)
28
+ io.stdout(
29
+ (
30
+ (await Bun.file(
31
+ new URL('../package.json', import.meta.url),
32
+ ).json()) as { version: string }
33
+ ).version,
34
+ )
29
35
  return 0
30
36
  }
31
37
  if (args[0] !== 'blueprint') {
32
- io.stderr('Usage: bunderstack blueprint [directory] [--entry <path>] [--output <path>] [--check]')
38
+ io.stderr(
39
+ 'Usage: bunderstack blueprint [directory] [--entry <path>] [--output <path>] [--check]',
40
+ )
33
41
  return 2
34
42
  }
35
43
  const options: GenerateBlueprintOptions = { directory: process.cwd() }
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'