bunderstack 0.15.0 → 0.15.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bunderstack",
3
- "version": "0.15.0",
3
+ "version": "0.15.2",
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",
@@ -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/crud.ts CHANGED
@@ -163,7 +163,11 @@ export function buildCrudRouter<TSchema extends Record<string, unknown>>(
163
163
  )
164
164
  }
165
165
 
166
- const scope = scopeFor(tableAccess.readScope, { user, session, request: c.req.raw })
166
+ const scope = scopeFor(tableAccess.readScope, {
167
+ user,
168
+ session,
169
+ request: c.req.raw,
170
+ })
167
171
  if (
168
172
  scope &&
169
173
  !rowMatchesScope(rows[0] as Record<string, unknown>, scope)
@@ -240,7 +244,12 @@ export function buildCrudRouter<TSchema extends Record<string, unknown>>(
240
244
  user?.id ?? null,
241
245
  )
242
246
 
243
- const scope = scopeFor(tableAccess.writeScope, { user, session, request: c.req.raw, body: body as Record<string, unknown> })
247
+ const scope = scopeFor(tableAccess.writeScope, {
248
+ user,
249
+ session,
250
+ request: c.req.raw,
251
+ body: body as Record<string, unknown>,
252
+ })
244
253
  const stamped = scope ? stampScope(values, scope) : values
245
254
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
246
255
  const rows = await (db as any).insert(table).values(stamped).returning()
@@ -279,7 +288,11 @@ export function buildCrudRouter<TSchema extends Record<string, unknown>>(
279
288
  return apiError(c, ErrorCode.NOT_FOUND, 'Not found', 404)
280
289
  }
281
290
 
282
- const readScope = scopeFor(tableAccess.readScope, { user, session, request: c.req.raw })
291
+ const readScope = scopeFor(tableAccess.readScope, {
292
+ user,
293
+ session,
294
+ request: c.req.raw,
295
+ })
283
296
  if (
284
297
  readScope &&
285
298
  !rowMatchesScope(existing[0] as Record<string, unknown>, readScope)
@@ -319,7 +332,12 @@ export function buildCrudRouter<TSchema extends Record<string, unknown>>(
319
332
  user?.id ?? null,
320
333
  )
321
334
 
322
- const writeScope = scopeFor(tableAccess.writeScope, { user, session, request: c.req.raw, body: body as Record<string, unknown> })
335
+ const writeScope = scopeFor(tableAccess.writeScope, {
336
+ user,
337
+ session,
338
+ request: c.req.raw,
339
+ body: body as Record<string, unknown>,
340
+ })
323
341
  const stamped = writeScope ? stampScope(values, writeScope) : values
324
342
 
325
343
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -352,7 +370,11 @@ export function buildCrudRouter<TSchema extends Record<string, unknown>>(
352
370
  return apiError(c, ErrorCode.NOT_FOUND, 'Not found', 404)
353
371
  }
354
372
 
355
- const scope = scopeFor(tableAccess.readScope, { user, session, request: c.req.raw })
373
+ const scope = scopeFor(tableAccess.readScope, {
374
+ user,
375
+ session,
376
+ request: c.req.raw,
377
+ })
356
378
  if (
357
379
  scope &&
358
380
  !rowMatchesScope(existing[0] as Record<string, unknown>, scope)
package/src/env.ts CHANGED
@@ -22,9 +22,10 @@ export type BaseEnv = {
22
22
  BUNDERSTACK_CRON_SECRET?: string
23
23
  }
24
24
 
25
- type InferVars<T> = T extends Record<string, ZodType>
26
- ? { [K in keyof T]: z.output<T[K]> }
27
- : unknown
25
+ type InferVars<T> =
26
+ T extends Record<string, ZodType>
27
+ ? { [K in keyof T]: z.output<T[K]> }
28
+ : unknown
28
29
 
29
30
  // Non-distributive so `ValidatedEnv<undefined>` is BaseEnv, not `never`.
30
31
  export type ValidatedEnv<TEnv extends EnvConfigInput | undefined> = [
@@ -113,8 +114,14 @@ export function validateEnv<TEnv extends EnvConfigInput | undefined>(
113
114
  if (isProduction && !source.AUTH_SECRET) {
114
115
  issues.push('AUTH_SECRET: required in production')
115
116
  }
116
- if (isProduction && options.cronConfigured && !source.BUNDERSTACK_CRON_SECRET) {
117
- issues.push('BUNDERSTACK_CRON_SECRET: required when cron is configured in production')
117
+ if (
118
+ isProduction &&
119
+ options.cronConfigured &&
120
+ !source.BUNDERSTACK_CRON_SECRET
121
+ ) {
122
+ issues.push(
123
+ 'BUNDERSTACK_CRON_SECRET: required when cron is configured in production',
124
+ )
118
125
  }
119
126
  if (options.emailProvider === 'resend' && !source.RESEND_API_KEY) {
120
127
  issues.push("RESEND_API_KEY: required when email provider is 'resend'")
@@ -121,7 +121,10 @@ const INTERNAL_TABLE_CANDIDATES = new Map<string, readonly unknown[]>([
121
121
  [bunderstackIdempotency, bunderstackIdempotencyPg],
122
122
  ],
123
123
  [getTableName(bunderstackJobs), [bunderstackJobs, bunderstackJobsPg]],
124
- [getTableName(bunderstackCronRuns), [bunderstackCronRuns, bunderstackCronRunsPg]],
124
+ [
125
+ getTableName(bunderstackCronRuns),
126
+ [bunderstackCronRuns, bunderstackCronRunsPg],
127
+ ],
125
128
  ])
126
129
 
127
130
  /** Internal file-meta table matching the db's dialect. */
@@ -1,12 +1,12 @@
1
- import { Hono } from 'hono'
2
1
  import { and, eq, lt } from 'drizzle-orm'
2
+ import { Hono } from 'hono'
3
3
 
4
4
  import type { AnyDb } from '../dialect'
5
5
  import type { BackgroundDefs } from './define'
6
6
 
7
+ import { cronRunsTableFor } from '../internal-tables'
7
8
  import { verifyScheduleRequest } from './cron-auth'
8
9
  import { runCronSlot, runScheduledSlot } from './cron-runner'
9
- import { cronRunsTableFor } from '../internal-tables'
10
10
 
11
11
  const MAX_SLOT_AGE_MS = 60 * 60_000
12
12
  const MAX_FUTURE_SLOT_MS = 60_000
@@ -56,10 +56,7 @@ export function buildCronRouter(args: {
56
56
  slot,
57
57
  now: current,
58
58
  })
59
- return c.json(
60
- result,
61
- result.status === 'running' ? 202 : 200,
62
- )
59
+ return c.json(result, result.status === 'running' ? 202 : 200)
63
60
  } catch (error) {
64
61
  if (
65
62
  error instanceof Error &&
@@ -79,7 +76,14 @@ export function buildCronRouter(args: {
79
76
  if (!slotText || !Number.isSafeInteger(slot) || !signature) {
80
77
  return c.json({ error: 'invalid schedule signature' }, 401)
81
78
  }
82
- if (!verifyScheduleRequest(args.secret, `maintenance:${name}`, slot, signature)) {
79
+ if (
80
+ !verifyScheduleRequest(
81
+ args.secret,
82
+ `maintenance:${name}`,
83
+ slot,
84
+ signature,
85
+ )
86
+ ) {
83
87
  return c.json({ error: 'invalid schedule signature' }, 401)
84
88
  }
85
89
  if (name !== 'storage-sweep') {
@@ -22,11 +22,13 @@ export async function runScheduledSlot(args: {
22
22
  run: (scheduledFor: Date) => Promise<void> | void
23
23
  leaseMs?: number
24
24
  heartbeatIntervalMs?: number
25
+ heartbeatCleanupTimeoutMs?: number
25
26
  }): Promise<CronRunResult> {
26
27
  const { db, taskId, schedule, slot, now, run } = args
27
28
  const leaseMs = args.leaseMs ?? LEASE_MS
28
29
  const heartbeatIntervalMs =
29
30
  args.heartbeatIntervalMs ?? Math.max(1, Math.floor(leaseMs / 4))
31
+ const heartbeatCleanupTimeoutMs = args.heartbeatCleanupTimeoutMs ?? 1_000
30
32
 
31
33
  if (slot % 60_000 !== 0 || !cronMatches(parseCron(schedule), slot)) {
32
34
  throw new Error('[bunderstack] cron slot does not match its schedule')
@@ -45,9 +47,12 @@ export async function runScheduledSlot(args: {
45
47
  startedAt: now,
46
48
  })
47
49
  .onConflictDoNothing({ target: [t.taskId, t.scheduledAt] })
48
- .returning({ taskId: t.taskId })
50
+ .returning({ taskId: t.taskId, attempts: t.attempts })
49
51
 
50
- if (!inserted[0]) {
52
+ let ownershipAttempt: number
53
+ if (inserted[0]) {
54
+ ownershipAttempt = Number(inserted[0].attempts)
55
+ } else {
51
56
  const existing = await db
52
57
  .select({ status: t.status, lockedUntil: t.lockedUntil })
53
58
  .from(t)
@@ -74,42 +79,75 @@ export async function runScheduledSlot(args: {
74
79
  or(eq(t.status, 'failed'), lt(t.lockedUntil, now)),
75
80
  ),
76
81
  )
77
- .returning({ taskId: t.taskId })
78
- if (!reclaimed[0]) return { status: 'running' }
82
+ .returning({ taskId: t.taskId, attempts: t.attempts })
83
+ const reclaimedRow = reclaimed[0]
84
+ if (!reclaimedRow) return { status: 'running' }
85
+ ownershipAttempt = Number(reclaimedRow.attempts)
79
86
  }
80
87
 
81
88
  let heartbeatTimer: Timer | undefined
82
- const stopHeartbeat = () => {
89
+ let heartbeatInFlight: Promise<void> | undefined
90
+ let heartbeatStopped = false
91
+
92
+ const scheduleHeartbeat = () => {
93
+ heartbeatTimer = setTimeout(() => {
94
+ heartbeatTimer = undefined
95
+ if (heartbeatStopped) return
96
+
97
+ heartbeatInFlight = (async () => {
98
+ try {
99
+ const renewUntil = Date.now() + leaseMs
100
+ await db
101
+ .update(t)
102
+ .set({ lockedUntil: renewUntil })
103
+ .where(
104
+ and(
105
+ eq(t.taskId, taskId),
106
+ eq(t.scheduledAt, slot),
107
+ eq(t.status, 'running'),
108
+ eq(t.startedAt, now),
109
+ eq(t.attempts, ownershipAttempt),
110
+ ),
111
+ )
112
+ } catch {
113
+ // Best effort renewal
114
+ }
115
+ })()
116
+
117
+ void heartbeatInFlight.finally(() => {
118
+ heartbeatInFlight = undefined
119
+ if (!heartbeatStopped) scheduleHeartbeat()
120
+ })
121
+ }, heartbeatIntervalMs)
122
+ }
123
+
124
+ const stopHeartbeat = async () => {
125
+ heartbeatStopped = true
83
126
  if (heartbeatTimer) {
84
- clearInterval(heartbeatTimer)
127
+ clearTimeout(heartbeatTimer)
85
128
  heartbeatTimer = undefined
86
129
  }
87
- }
130
+ const inFlight = heartbeatInFlight
131
+ if (!inFlight) return
88
132
 
89
- heartbeatTimer = setInterval(async () => {
133
+ let cleanupTimer: Timer | undefined
90
134
  try {
91
- const renewUntil = Date.now() + leaseMs
92
- await db
93
- .update(t)
94
- .set({ lockedUntil: renewUntil })
95
- .where(
96
- and(
97
- eq(t.taskId, taskId),
98
- eq(t.scheduledAt, slot),
99
- eq(t.status, 'running'),
100
- eq(t.startedAt, now),
101
- ),
102
- )
103
- } catch {
104
- // Best effort renewal
135
+ await Promise.race([
136
+ inFlight,
137
+ new Promise<void>((resolve) => {
138
+ cleanupTimer = setTimeout(resolve, heartbeatCleanupTimeoutMs)
139
+ }),
140
+ ])
141
+ } finally {
142
+ if (cleanupTimer) clearTimeout(cleanupTimer)
105
143
  }
106
- }, heartbeatIntervalMs)
144
+ }
145
+
146
+ scheduleHeartbeat()
107
147
 
108
148
  try {
109
149
  await run(new Date(slot))
110
150
 
111
- stopHeartbeat()
112
-
113
151
  const updated = await db
114
152
  .update(t)
115
153
  .set({ status: 'succeeded', lockedUntil: null, finishedAt: Date.now() })
@@ -119,6 +157,7 @@ export async function runScheduledSlot(args: {
119
157
  eq(t.scheduledAt, slot),
120
158
  eq(t.status, 'running'),
121
159
  eq(t.startedAt, now),
160
+ eq(t.attempts, ownershipAttempt),
122
161
  ),
123
162
  )
124
163
  .returning({ taskId: t.taskId })
@@ -131,8 +170,6 @@ export async function runScheduledSlot(args: {
131
170
 
132
171
  return { status: 'succeeded' }
133
172
  } catch (error) {
134
- stopHeartbeat()
135
-
136
173
  const message = error instanceof Error ? error.message : String(error)
137
174
  await db
138
175
  .update(t)
@@ -148,11 +185,12 @@ export async function runScheduledSlot(args: {
148
185
  eq(t.scheduledAt, slot),
149
186
  eq(t.status, 'running'),
150
187
  eq(t.startedAt, now),
188
+ eq(t.attempts, ownershipAttempt),
151
189
  ),
152
190
  )
153
191
  throw error
154
192
  } finally {
155
- stopHeartbeat()
193
+ await stopHeartbeat()
156
194
  }
157
195
  }
158
196
 
@@ -165,6 +203,7 @@ export async function runCronSlot(args: {
165
203
  now: number
166
204
  leaseMs?: number
167
205
  heartbeatIntervalMs?: number
206
+ heartbeatCleanupTimeoutMs?: number
168
207
  }): Promise<CronRunResult> {
169
208
  const definition = args.defs[args.name]
170
209
  if (!definition || definition.kind !== 'cron') {
@@ -178,6 +217,7 @@ export async function runCronSlot(args: {
178
217
  now: args.now,
179
218
  leaseMs: args.leaseMs,
180
219
  heartbeatIntervalMs: args.heartbeatIntervalMs,
220
+ heartbeatCleanupTimeoutMs: args.heartbeatCleanupTimeoutMs,
181
221
  run: (scheduledFor) =>
182
222
  definition.handler({ scheduledFor }, args.ctx as never),
183
223
  })
package/src/jobs/index.ts CHANGED
@@ -30,7 +30,11 @@ export type { CronRunResult } from './cron-runner'
30
30
  export { buildCronRouter } from './cron-router'
31
31
  export { signScheduleRequest, verifyScheduleRequest } from './cron-auth'
32
32
  export { startJobWorker } from './runtime'
33
- export type { StartWorkerOptions, RunWorkerOptions, WorkerHandle } from './runtime'
33
+ export type {
34
+ StartWorkerOptions,
35
+ RunWorkerOptions,
36
+ WorkerHandle,
37
+ } from './runtime'
34
38
  export { startLocalCronScheduler } from './local-cron'
35
39
  export type {
36
40
  LocalCronScheduler,
@@ -4,11 +4,7 @@ import { and, eq, inArray, is, isNotNull, lt, lte, sql } from 'drizzle-orm'
4
4
  import { PgDatabase } from 'drizzle-orm/pg-core'
5
5
 
6
6
  import type { AnyDb } from '../dialect'
7
- import type {
8
- AnyJobDefinition,
9
- JobsDefs,
10
- JobsRuntimeFacade,
11
- } from './define'
7
+ import type { AnyJobDefinition, JobsDefs, JobsRuntimeFacade } from './define'
12
8
 
13
9
  import { jobsTableFor } from '../internal-tables'
14
10
  import { backoffMs, DEFAULT_RETRIES, DEFAULT_TIMEOUT_MS } from './define'
@@ -77,7 +73,11 @@ export function createJobRunner(deps: {
77
73
  })
78
74
  .from(t)
79
75
  .where(
80
- and(eq(t.status, 'running'), isNotNull(t.lockedUntil), lt(t.lockedUntil, now)),
76
+ and(
77
+ eq(t.status, 'running'),
78
+ isNotNull(t.lockedUntil),
79
+ lt(t.lockedUntil, now),
80
+ ),
81
81
  )
82
82
  for (const row of expired) {
83
83
  const def = defs[row.type]
@@ -148,8 +148,11 @@ export function createJobRunner(deps: {
148
148
  // PG: lock the selected rows so concurrent replicas skip them. SQLite's
149
149
  // single-writer model makes the one-statement UPDATE atomic on its own.
150
150
  const sub = is(db, PgDatabase)
151
- ? (pendingIds as unknown as { for: (m: string, o: object) => typeof pendingIds })
152
- .for('update', { skipLocked: true })
151
+ ? (
152
+ pendingIds as unknown as {
153
+ for: (m: string, o: object) => typeof pendingIds
154
+ }
155
+ ).for('update', { skipLocked: true })
153
156
  : pendingIds
154
157
  const rows: JobRow[] = await db
155
158
  .update(t)
package/src/list-query.ts CHANGED
@@ -16,8 +16,8 @@ import {
16
16
  } from 'drizzle-orm'
17
17
  import { PgTable } from 'drizzle-orm/pg-core'
18
18
 
19
- import type { AnyDb } from './dialect'
20
19
  import type { ResolvedTableAccess, SortOrder } from './access'
20
+ import type { AnyDb } from './dialect'
21
21
 
22
22
  import { ErrorCode, ListQueryError } from './errors'
23
23
 
@@ -282,9 +282,7 @@ export function encodeCursor(payload: CursorPayload): string {
282
282
 
283
283
  export function decodeCursor(cursor: string): CursorPayload {
284
284
  try {
285
- const parsed = JSON.parse(
286
- Buffer.from(cursor, 'base64url').toString('utf8'),
287
- )
285
+ const parsed = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8'))
288
286
  if (!isCursorPayload(parsed)) {
289
287
  throw new Error('invalid cursor shape')
290
288
  }
package/src/manifest.ts CHANGED
@@ -5,13 +5,14 @@ import type { Dialect } from './dialect'
5
5
  import type { EnvConfigInput } from './env'
6
6
  import type { JobsDefs } from './jobs/define'
7
7
  import type { ResolvedBucket, ResolvedStorageBuckets } from './storage/buckets'
8
- import { parseCron } from './jobs/cron'
8
+
9
9
  import {
10
10
  bunderstackCronRuns,
11
11
  bunderstackFiles,
12
12
  bunderstackIdempotency,
13
13
  bunderstackJobs,
14
14
  } from './internal-tables'
15
+ import { parseCron } from './jobs/cron'
15
16
 
16
17
  export type ManifestEnvVar = {
17
18
  key: string
@@ -47,8 +48,12 @@ const nonEmpty = z.string().min(1)
47
48
  const migrationDirectory = nonEmpty.refine(
48
49
  (value) =>
49
50
  value.startsWith('/') ||
50
- (!value.includes('\\') && value.split('/').every((part) => part !== '..' && part !== '')),
51
- { message: 'migrationsDirectory must be an absolute path or a relative path without traversal' },
51
+ (!value.includes('\\') &&
52
+ value.split('/').every((part) => part !== '..' && part !== '')),
53
+ {
54
+ message:
55
+ 'migrationsDirectory must be an absolute path or a relative path without traversal',
56
+ },
52
57
  )
53
58
  const cronSchedule = nonEmpty.refine(
54
59
  (value) => {
@@ -85,7 +90,10 @@ const manifestSchema = z
85
90
  defaultBucket: nonEmpty,
86
91
  buckets: z.array(
87
92
  z
88
- .object({ name: nonEmpty, visibility: z.enum(['public', 'private']) })
93
+ .object({
94
+ name: nonEmpty,
95
+ visibility: z.enum(['public', 'private']),
96
+ })
89
97
  .strict(),
90
98
  ),
91
99
  })
@@ -133,13 +141,16 @@ function sortBy<T>(entries: readonly T[], key: (entry: T) => string): T[] {
133
141
  function rejectDuplicates(collection: string, values: readonly string[]): void {
134
142
  const seen = new Set<string>()
135
143
  for (const value of values) {
136
- if (seen.has(value)) throw new Error(`[bunderstack] duplicate ${collection} "${value}"`)
144
+ if (seen.has(value))
145
+ throw new Error(`[bunderstack] duplicate ${collection} "${value}"`)
137
146
  seen.add(value)
138
147
  }
139
148
  }
140
149
 
141
150
  function describeTables(schema: Record<string, unknown>) {
142
- const systemNames = new Set<string>(systemTables().map((table) => table.physicalName))
151
+ const systemNames = new Set<string>(
152
+ systemTables().map((table) => table.physicalName),
153
+ )
143
154
  return sortBy(
144
155
  Object.entries(schema).flatMap(([exportName, value]) =>
145
156
  isTable(value) && !systemNames.has(getTableName(value))
@@ -163,13 +174,21 @@ function describeSection(
163
174
 
164
175
  function systemTables() {
165
176
  return [
166
- { exportName: '_system.files', physicalName: getTableName(bunderstackFiles), system: true },
177
+ {
178
+ exportName: '_system.files',
179
+ physicalName: getTableName(bunderstackFiles),
180
+ system: true,
181
+ },
167
182
  {
168
183
  exportName: '_system.idempotency',
169
184
  physicalName: getTableName(bunderstackIdempotency),
170
185
  system: true,
171
186
  },
172
- { exportName: '_system.jobs', physicalName: getTableName(bunderstackJobs), system: true },
187
+ {
188
+ exportName: '_system.jobs',
189
+ physicalName: getTableName(bunderstackJobs),
190
+ system: true,
191
+ },
173
192
  {
174
193
  exportName: '_system.scheduledRuns',
175
194
  physicalName: getTableName(bunderstackCronRuns),
@@ -180,13 +199,34 @@ function systemTables() {
180
199
 
181
200
  export function parseManifest(value: unknown): BunderstackManifest {
182
201
  const manifest = manifestSchema.parse(value) as BunderstackManifest
183
- rejectDuplicates('database physical table', manifest.database.tables.map((entry) => entry.physicalName))
184
- rejectDuplicates('database export table', manifest.database.tables.map((entry) => entry.exportName))
185
- rejectDuplicates('storage bucket', manifest.storage.buckets.map((entry) => entry.name))
186
- rejectDuplicates('environment key', manifest.environment.map((entry) => entry.key))
187
- rejectDuplicates('background job', manifest.background.jobs.map((entry) => entry.name))
188
- rejectDuplicates('background cron', manifest.background.cron.map((entry) => entry.name))
189
- rejectDuplicates('background maintenance', manifest.background.maintenance.map((entry) => entry.name))
202
+ rejectDuplicates(
203
+ 'database physical table',
204
+ manifest.database.tables.map((entry) => entry.physicalName),
205
+ )
206
+ rejectDuplicates(
207
+ 'database export table',
208
+ manifest.database.tables.map((entry) => entry.exportName),
209
+ )
210
+ rejectDuplicates(
211
+ 'storage bucket',
212
+ manifest.storage.buckets.map((entry) => entry.name),
213
+ )
214
+ rejectDuplicates(
215
+ 'environment key',
216
+ manifest.environment.map((entry) => entry.key),
217
+ )
218
+ rejectDuplicates(
219
+ 'background job',
220
+ manifest.background.jobs.map((entry) => entry.name),
221
+ )
222
+ rejectDuplicates(
223
+ 'background cron',
224
+ manifest.background.cron.map((entry) => entry.name),
225
+ )
226
+ rejectDuplicates(
227
+ 'background maintenance',
228
+ manifest.background.maintenance.map((entry) => entry.name),
229
+ )
190
230
  return manifest
191
231
  }
192
232
 
@@ -210,14 +250,20 @@ export function buildManifest(args: {
210
250
  ? [{ key: 'SMTP_URL', required: true, scope: 'server' as const }]
211
251
  : []),
212
252
  ]
213
- rejectDuplicates('environment key', environment.map((entry) => entry.key))
253
+ rejectDuplicates(
254
+ 'environment key',
255
+ environment.map((entry) => entry.key),
256
+ )
214
257
 
215
258
  return parseManifest({
216
259
  version: 3,
217
260
  database: {
218
261
  dialect: args.dialect,
219
262
  migrationsDirectory: args.migrationsDirectory,
220
- tables: sortBy([...systemTables(), ...describeTables(args.schema)], (entry) => entry.physicalName),
263
+ tables: sortBy(
264
+ [...systemTables(), ...describeTables(args.schema)],
265
+ (entry) => entry.physicalName,
266
+ ),
221
267
  },
222
268
  storage: {
223
269
  defaultBucket: args.storage.defaultBucket,
@@ -249,7 +295,11 @@ export function buildManifest(args: {
249
295
  (entry) => entry.name,
250
296
  ),
251
297
  maintenance: [
252
- { name: 'storage-sweep', schedule: '0 4 * * *', timezone: 'UTC' as const },
298
+ {
299
+ name: 'storage-sweep',
300
+ schedule: '0 4 * * *',
301
+ timezone: 'UTC' as const,
302
+ },
253
303
  ],
254
304
  },
255
305
  })
@@ -266,7 +266,8 @@ export function resolveBuckets(
266
266
  input: StorageConfigInput | undefined,
267
267
  env: Record<string, string | undefined> = process.env,
268
268
  ): ResolvedStorageBuckets {
269
- const sharedBackend = platformS3Backend(env) ?? resolveSharedBackend(input, env)
269
+ const sharedBackend =
270
+ platformS3Backend(env) ?? resolveSharedBackend(input, env)
270
271
  const bucketsInput = input?.buckets
271
272
 
272
273
  const declaredNames = bucketsInput ? Object.keys(bucketsInput) : []
@@ -29,8 +29,8 @@
29
29
 
30
30
  import { eq, and, lt, sql } from 'drizzle-orm'
31
31
 
32
- import type { AnyDb } from '../dialect'
33
32
  import type { ScopeMap } from '../access'
33
+ import type { AnyDb } from '../dialect'
34
34
 
35
35
  import { rowMatchesScope } from '../access'
36
36
  import { bunderstackFiles, filesTableFor } from '../internal-tables'
@@ -27,11 +27,7 @@ import {
27
27
  sumReadySize,
28
28
  type FileMetaRow,
29
29
  } from './file-meta'
30
- import {
31
- parseTransformSpec,
32
- transformHash,
33
- transformImage,
34
- } from './thumbnails'
30
+ import { parseTransformSpec, transformHash, transformImage } from './thumbnails'
35
31
 
36
32
  export interface BucketStorageRouterOptions {
37
33
  registry: BucketStorageRegistry
@@ -379,7 +375,7 @@ export function buildBucketStorageRouter(
379
375
  if (!entry) return apiError(c, ErrorCode.NOT_FOUND, 'Unknown bucket', 404)
380
376
  const { bucket, adapter } = entry
381
377
 
382
- const mountPrefix = c.req.routePath.replace(/\/:[^\/]+\/\*$/, '')
378
+ const mountPrefix = c.req.routePath.replace(/\/:[^/]+\/\*$/, '')
383
379
  const id = c.req.path.slice(`${mountPrefix}/${bucketName}/`.length)
384
380
  const fileId = `${bucketName}/${id}`
385
381
 
package/src/storage/s3.ts CHANGED
@@ -39,11 +39,17 @@ export class S3StorageAdapter implements StorageAdapter {
39
39
  }
40
40
 
41
41
  async get(fileId: string): Promise<Response> {
42
- const exists = await this.client.exists(fileId)
43
- if (!exists) return new Response('Not found', { status: 404 })
42
+ // `stat` (a HEAD, same cost as `exists`) is the only way to learn the
43
+ // stored Content-Type: a lazily-constructed `client.file(key)` reports
44
+ // `type: ""` — it never stats, and unlike `Bun.file` it does not guess
45
+ // from the extension either.
46
+ const info = await this.stat(fileId)
47
+ if (!info) return new Response('Not found', { status: 404 })
44
48
  const file = this.client.file(fileId)
45
49
  return new Response(file.stream(), {
46
- headers: { 'Content-Type': file.type ?? 'application/octet-stream' },
50
+ headers: {
51
+ 'Content-Type': info.contentType || 'application/octet-stream',
52
+ },
47
53
  })
48
54
  }
49
55
 
package/src/trpc.ts CHANGED
@@ -5,9 +5,9 @@ import superjson from 'superjson'
5
5
  import type { AccessUser } from './access'
6
6
  import type { DbFor } from './db'
7
7
  import type { EmailFacade } from './email'
8
+ import type { StorageFacade } from './index'
8
9
  import type { JobsRuntimeFacade } from './jobs/index'
9
10
  import type { RealtimeFacade } from './realtime/facade'
10
- import type { StorageFacade } from './index'
11
11
 
12
12
  export type TRPCContext<
13
13
  TSchema extends Record<string, unknown>,