bunderstack 0.13.0 → 0.15.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
@@ -52,20 +52,25 @@ through env vars alone — no code changes required.
52
52
  Plain `DATABASE_URL` / `S3_*` vars keep their usual role: fallbacks that
53
53
  code-level config wins over.
54
54
 
55
- ### Introspection
55
+ ### Committed deployment blueprint
56
56
 
57
- Set `BUNDERSTACK_INTROSPECT=1` and import the app declaration: the boot is
58
- guaranteed offline (the selected adapter returns its `drizzle.mock({ schema })`
59
- database, no Redis) and missing user env vars don't throw. Then read
60
- `app.manifest`:
57
+ Generate a deterministic, provider-neutral declaration that a host can read
58
+ without importing the application at deploy time. Version 1 supports TanStack
59
+ Start apps and records database tables and migration mode, storage buckets,
60
+ environment requirements, realtime, jobs, cron, and maintenance schedules.
61
61
 
62
- ```ts
63
- process.env.BUNDERSTACK_INTROSPECT = '1'
64
- const { app } = await import('./src/bunderstack')
65
- console.log(JSON.stringify(app.manifest))
66
- // { version: 2, dialect, tables, tableMap, systemTables, background, ... }
62
+ ```sh
63
+ bunx bunderstack blueprint
64
+ bunx bunderstack blueprint --check
67
65
  ```
68
66
 
67
+ The command imports `src/bunderstack.ts` by default (or
68
+ `package.json#bunderstack.entry`) with `BUNDERSTACK_INTROSPECT=1`, so it never
69
+ opens external database, storage, or realtime connections. Commit the generated
70
+ `bunderstack.blueprint.yaml`; CI should run the `--check` form. The public
71
+ `bunderstack/blueprint` module exposes the strict parser and conversion helpers
72
+ for hosts and other tooling.
73
+
69
74
  Real database clients belong to the app. Call `await app.close()` when a
70
75
  standalone process or test is finished; it closes the real libSQL, PGlite,
71
76
  postgres.js, or Bun SQL client selected by `database.adapter`. Introspection
@@ -93,8 +98,9 @@ queue handlers never publish realtime events, acknowledge the process-local
93
98
  behavior with `app.runWorker({ allowProcessLocalRealtime: true })`.
94
99
 
95
100
  Inspect the active runtime with `app.realtime.transport` (`'disabled'`,
96
- `'memory'`, or `'redis'`). Deploy tooling can read the configured transport
97
- from `app.manifest.realtimeTransport`.
101
+ `'memory'`, or `'redis'`). The generated blueprint declares only whether
102
+ realtime is required; the host chooses its shared transport and injects its
103
+ runtime configuration.
98
104
 
99
105
  ```ts
100
106
  const app = await createBunderstack({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bunderstack",
3
- "version": "0.13.0",
3
+ "version": "0.15.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",
@@ -20,6 +20,9 @@
20
20
  "url": "git+https://github.com/kirill-dev-pro/bunderstack.git",
21
21
  "directory": "packages/bunderstack"
22
22
  },
23
+ "bin": {
24
+ "bunderstack": "./src/cli.ts"
25
+ },
23
26
  "files": [
24
27
  "src",
25
28
  "!src/**/*.test.ts",
@@ -43,6 +46,7 @@
43
46
  "./typeid": "./src/typeid.ts",
44
47
  "./typeid/pg": "./src/typeid-pg.ts",
45
48
  "./env": "./src/env.ts",
49
+ "./blueprint": "./src/blueprint.ts",
46
50
  "./trpc": "./src/trpc.ts",
47
51
  "./cron": "./src/cron.ts",
48
52
  "./email/smtp": "./src/email/smtp.ts"
@@ -54,7 +58,8 @@
54
58
  "db:migrate": "drizzle-kit migrate"
55
59
  },
56
60
  "dependencies": {
57
- "superjson": "^2.2.0"
61
+ "superjson": "^2.2.0",
62
+ "yaml": "^2.9.0"
58
63
  },
59
64
  "devDependencies": {
60
65
  "@electric-sql/pglite": ">=0.3.0",
@@ -0,0 +1,157 @@
1
+ import { mkdir, readFile, realpath, rename, rm, writeFile } from 'node:fs/promises'
2
+ import { dirname, isAbsolute, join, relative, resolve } from 'node:path'
3
+ import { pathToFileURL } from 'node:url'
4
+
5
+ import {
6
+ blueprintFromManifest,
7
+ serializeBlueprint,
8
+ type BunderstackBlueprint,
9
+ } from './blueprint'
10
+ import { parseManifest } from './manifest'
11
+
12
+ export type GenerateBlueprintOptions = {
13
+ directory: string
14
+ entry?: string
15
+ output?: string
16
+ check?: boolean
17
+ }
18
+
19
+ export type GenerateBlueprintResult = {
20
+ path: string
21
+ blueprint: BunderstackBlueprint
22
+ source: string
23
+ changed: boolean
24
+ }
25
+
26
+ export class BlueprintCheckError extends Error {
27
+ constructor() {
28
+ super('bunderstack.blueprint.yaml is missing or stale; run `bunderstack blueprint`')
29
+ this.name = 'BlueprintCheckError'
30
+ }
31
+ }
32
+
33
+ type AppPackage = {
34
+ scripts?: Record<string, unknown>
35
+ dependencies?: Record<string, unknown>
36
+ devDependencies?: Record<string, unknown>
37
+ bunderstack?: { entry?: unknown }
38
+ }
39
+
40
+ function requireRelativePath(value: string, label: string): string {
41
+ const normalized = value.replaceAll('\\', '/')
42
+ if (
43
+ !normalized ||
44
+ isAbsolute(normalized) ||
45
+ normalized.split('/').some((part) => !part || part === '..')
46
+ ) {
47
+ throw new Error(`[bunderstack] ${label} must be a relative path without traversal`)
48
+ }
49
+ return normalized
50
+ }
51
+
52
+ function resolveWithin(root: string, value: string, label: string): string {
53
+ const path = resolve(root, requireRelativePath(value, label))
54
+ const pathFromRoot = relative(root, path)
55
+ if (pathFromRoot === '..' || pathFromRoot.startsWith('../') || isAbsolute(pathFromRoot)) {
56
+ throw new Error(`[bunderstack] ${label} must stay within the application directory`)
57
+ }
58
+ return path
59
+ }
60
+
61
+ function normalizeProjectPath(root: string, value: string, label: string): string {
62
+ if (!isAbsolute(value)) return requireRelativePath(value, label)
63
+ const pathFromRoot = relative(root, resolve(value)) || '.'
64
+ return requireRelativePath(pathFromRoot, label)
65
+ }
66
+
67
+ function requireScript(pkg: AppPackage, name: 'build' | 'start' | 'worker', required: boolean): boolean {
68
+ const value = pkg.scripts?.[name]
69
+ if (typeof value === 'string' && value.trim()) return true
70
+ if (required) throw new Error(`[bunderstack] package.json requires a non-empty "${name}" script`)
71
+ return false
72
+ }
73
+
74
+ async function packageVersion(): Promise<string> {
75
+ const pkg = (await Bun.file(new URL('../package.json', import.meta.url)).json()) as { version: string }
76
+ return pkg.version
77
+ }
78
+
79
+ export async function generateBlueprint(
80
+ options: GenerateBlueprintOptions,
81
+ ): Promise<GenerateBlueprintResult> {
82
+ const directory = await realpath(resolve(options.directory))
83
+ const packagePath = join(directory, 'package.json')
84
+ const pkg = JSON.parse(await readFile(packagePath, 'utf8')) as AppPackage
85
+ const allDependencies = { ...pkg.dependencies, ...pkg.devDependencies }
86
+ if (typeof allDependencies['@tanstack/react-start'] !== 'string') {
87
+ throw new Error('[bunderstack] package.json must depend on @tanstack/react-start')
88
+ }
89
+ requireScript(pkg, 'build', true)
90
+ requireScript(pkg, 'start', true)
91
+
92
+ const configuredEntry = pkg.bunderstack?.entry
93
+ const entry = requireRelativePath(
94
+ options.entry ?? (typeof configuredEntry === 'string' ? configuredEntry : 'src/bunderstack.ts'),
95
+ 'entry',
96
+ )
97
+ const entryPath = resolveWithin(directory, entry, 'entry')
98
+ if (!(await Bun.file(entryPath).exists())) {
99
+ throw new Error(`[bunderstack] entry does not exist: ${entry}`)
100
+ }
101
+ const output = requireRelativePath(options.output ?? 'bunderstack.blueprint.yaml', 'output')
102
+ const outputPath = resolveWithin(directory, output, 'output')
103
+
104
+ const previousIntrospection = process.env.BUNDERSTACK_INTROSPECT
105
+ process.env.BUNDERSTACK_INTROSPECT = '1'
106
+ let app: { manifest?: unknown; close?: () => Promise<void> } | undefined
107
+ try {
108
+ const module = (await import(`${pathToFileURL(entryPath).href}?blueprint=${Date.now()}`)) as {
109
+ app?: typeof app
110
+ }
111
+ app = module.app
112
+ if (!app) throw new Error(`[bunderstack] ${entry} must export app`)
113
+ const manifest = parseManifest(app.manifest)
114
+ const workerRequired = manifest.background.jobs.length > 0
115
+ requireScript(pkg, 'worker', workerRequired)
116
+ const migrationsDirectory = normalizeProjectPath(
117
+ directory,
118
+ manifest.database.migrationsDirectory,
119
+ 'migrationsDirectory',
120
+ )
121
+ const migrationJournal = join(
122
+ resolveWithin(directory, migrationsDirectory, 'migrationsDirectory'),
123
+ 'meta',
124
+ '_journal.json',
125
+ )
126
+ const migrationMode = (await Bun.file(migrationJournal).exists()) ? 'migrations' : 'push'
127
+ const blueprint = blueprintFromManifest({
128
+ manifest: {
129
+ ...manifest,
130
+ database: { ...manifest.database, migrationsDirectory },
131
+ },
132
+ generatorVersion: await packageVersion(),
133
+ entry,
134
+ migrationMode,
135
+ })
136
+ const source = serializeBlueprint(blueprint)
137
+ const existing = (await Bun.file(outputPath).exists()) ? await Bun.file(outputPath).text() : undefined
138
+ if (options.check) {
139
+ if (existing !== source) throw new BlueprintCheckError()
140
+ return { path: outputPath, blueprint, source, changed: false }
141
+ }
142
+ if (existing === source) return { path: outputPath, blueprint, source, changed: false }
143
+ await mkdir(dirname(outputPath), { recursive: true })
144
+ const temporary = `${outputPath}.${process.pid}.${Date.now()}.tmp`
145
+ try {
146
+ await writeFile(temporary, source, { mode: 0o600 })
147
+ await rename(temporary, outputPath)
148
+ } finally {
149
+ await rm(temporary, { force: true })
150
+ }
151
+ return { path: outputPath, blueprint, source, changed: true }
152
+ } finally {
153
+ await app?.close?.()
154
+ if (previousIntrospection === undefined) delete process.env.BUNDERSTACK_INTROSPECT
155
+ else process.env.BUNDERSTACK_INTROSPECT = previousIntrospection
156
+ }
157
+ }
@@ -0,0 +1,188 @@
1
+ import { parse, stringify } from 'yaml'
2
+ import { z } from 'zod'
3
+
4
+ import { parseCron } from './jobs/cron'
5
+ import type { BunderstackManifest } from './manifest'
6
+
7
+ export type MigrationMode = 'migrations' | 'push'
8
+
9
+ export type BunderstackBlueprint = {
10
+ version: 1
11
+ generator: { name: 'bunderstack'; version: string }
12
+ application: {
13
+ framework: 'tanstack-start'
14
+ scripts: { build: 'build'; start: 'start'; worker?: 'worker' }
15
+ }
16
+ bunderstack: { entry: string; manifestVersion: 3 }
17
+ resources: {
18
+ database: BunderstackManifest['database'] & { migrationMode: MigrationMode }
19
+ storage: BunderstackManifest['storage']
20
+ realtime?: { required: true }
21
+ }
22
+ environment: BunderstackManifest['environment']
23
+ background: BunderstackManifest['background'] & {
24
+ worker: { required: boolean }
25
+ }
26
+ }
27
+
28
+ const nonEmpty = z.string().min(1)
29
+ const relativePath = nonEmpty.refine(
30
+ (value) =>
31
+ !value.startsWith('/') &&
32
+ !value.includes('\\') &&
33
+ value.split('/').every((part) => part !== '' && part !== '..'),
34
+ { message: 'entry must be a relative path without traversal' },
35
+ )
36
+ const cronSchedule = nonEmpty.refine(
37
+ (value) => {
38
+ try {
39
+ parseCron(value)
40
+ return true
41
+ } catch {
42
+ return false
43
+ }
44
+ },
45
+ { message: 'invalid cron schedule' },
46
+ )
47
+
48
+ const blueprintSchema = z
49
+ .object({
50
+ version: z.literal(1),
51
+ generator: z.object({ name: z.literal('bunderstack'), version: nonEmpty }).strict(),
52
+ application: z
53
+ .object({
54
+ framework: z.literal('tanstack-start'),
55
+ scripts: z
56
+ .object({ build: z.literal('build'), start: z.literal('start'), worker: z.literal('worker').optional() })
57
+ .strict(),
58
+ })
59
+ .strict(),
60
+ bunderstack: z
61
+ .object({ entry: relativePath, manifestVersion: z.literal(3) })
62
+ .strict(),
63
+ resources: z
64
+ .object({
65
+ database: z
66
+ .object({
67
+ dialect: z.enum(['sqlite', 'pg']),
68
+ migrationsDirectory: relativePath,
69
+ migrationMode: z.enum(['migrations', 'push']),
70
+ tables: z.array(
71
+ z.object({ exportName: nonEmpty, physicalName: nonEmpty, system: z.boolean() }).strict(),
72
+ ),
73
+ })
74
+ .strict(),
75
+ storage: z
76
+ .object({
77
+ defaultBucket: nonEmpty,
78
+ buckets: z.array(z.object({ name: nonEmpty, visibility: z.enum(['public', 'private']) }).strict()),
79
+ })
80
+ .strict(),
81
+ realtime: z.object({ required: z.literal(true) }).strict().optional(),
82
+ })
83
+ .strict(),
84
+ environment: z.array(
85
+ z.object({ key: nonEmpty, required: z.boolean(), scope: z.enum(['server', 'client']) }).strict(),
86
+ ),
87
+ background: z
88
+ .object({
89
+ worker: z.object({ required: z.boolean() }).strict(),
90
+ jobs: z.array(z.object({ name: nonEmpty }).strict()),
91
+ cron: z.array(z.object({ name: nonEmpty, schedule: cronSchedule, timezone: z.literal('UTC') }).strict()),
92
+ maintenance: z.array(
93
+ z.object({ name: z.literal('storage-sweep'), schedule: cronSchedule, timezone: z.literal('UTC') }).strict(),
94
+ ),
95
+ })
96
+ .strict(),
97
+ })
98
+ .strict()
99
+
100
+ function sortBy<T>(entries: readonly T[], key: (entry: T) => string): T[] {
101
+ return [...entries].sort((left, right) => key(left).localeCompare(key(right)))
102
+ }
103
+
104
+ function rejectDuplicates(collection: string, values: readonly string[]): void {
105
+ const seen = new Set<string>()
106
+ for (const value of values) {
107
+ if (seen.has(value)) throw new Error(`[bunderstack] duplicate ${collection} "${value}"`)
108
+ seen.add(value)
109
+ }
110
+ }
111
+
112
+ export function parseBlueprint(value: unknown): BunderstackBlueprint {
113
+ 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')
123
+ }
124
+ const workerRequired = blueprint.background.jobs.length > 0
125
+ if (blueprint.background.worker.required !== workerRequired) {
126
+ throw new Error('[bunderstack] background worker.required must match declared queue jobs')
127
+ }
128
+ if (Boolean(blueprint.application.scripts.worker) !== workerRequired) {
129
+ throw new Error('[bunderstack] application worker script must match declared queue jobs')
130
+ }
131
+ return blueprint
132
+ }
133
+
134
+ export function blueprintFromManifest(args: {
135
+ manifest: BunderstackManifest
136
+ generatorVersion: string
137
+ entry: string
138
+ migrationMode: MigrationMode
139
+ }): BunderstackBlueprint {
140
+ const workerRequired = args.manifest.background.jobs.length > 0
141
+ return parseBlueprint({
142
+ version: 1,
143
+ generator: { name: 'bunderstack', version: args.generatorVersion },
144
+ application: {
145
+ framework: 'tanstack-start',
146
+ scripts: { build: 'build', start: 'start', ...(workerRequired ? { worker: 'worker' } : {}) },
147
+ },
148
+ bunderstack: { entry: args.entry, manifestVersion: 3 },
149
+ resources: {
150
+ database: {
151
+ ...args.manifest.database,
152
+ migrationMode: args.migrationMode,
153
+ tables: sortBy(args.manifest.database.tables, (entry) => entry.physicalName),
154
+ },
155
+ storage: {
156
+ ...args.manifest.storage,
157
+ buckets: sortBy(args.manifest.storage.buckets, (entry) => entry.name),
158
+ },
159
+ ...(args.manifest.realtime.required ? { realtime: { required: true } } : {}),
160
+ },
161
+ environment: sortBy(args.manifest.environment, (entry) => entry.key),
162
+ background: {
163
+ worker: { required: workerRequired },
164
+ jobs: sortBy(args.manifest.background.jobs, (entry) => entry.name),
165
+ cron: sortBy(args.manifest.background.cron, (entry) => entry.name),
166
+ maintenance: sortBy(args.manifest.background.maintenance, (entry) => entry.name),
167
+ },
168
+ })
169
+ }
170
+
171
+ export function parseBlueprintYaml(source: string): BunderstackBlueprint {
172
+ return parseBlueprint(parse(source) as unknown)
173
+ }
174
+
175
+ export function serializeBlueprint(value: BunderstackBlueprint): string {
176
+ const blueprint = parseBlueprint(value)
177
+ const options = {
178
+ aliasDuplicateObjects: false,
179
+ defaultKeyType: 'PLAIN',
180
+ defaultStringType: 'PLAIN',
181
+ lineWidth: 0,
182
+ } 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
+ })
188
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,82 @@
1
+ #!/usr/bin/env bun
2
+ import {
3
+ BlueprintCheckError,
4
+ generateBlueprint,
5
+ type GenerateBlueprintOptions,
6
+ } from './blueprint-generator'
7
+
8
+ export type CliIo = {
9
+ stdout(message: string): void
10
+ stderr(message: string): void
11
+ }
12
+
13
+ const help = `Usage: bunderstack blueprint [directory] [--entry <path>] [--output <path>] [--check]
14
+
15
+ Generate a committed deployment declaration for a TanStack Start application.
16
+ Entry precedence: --entry, package.json#bunderstack.entry, src/bunderstack.ts.`
17
+
18
+ export async function runCli(
19
+ args: string[],
20
+ io: CliIo,
21
+ generate: typeof generateBlueprint = generateBlueprint,
22
+ ): Promise<number> {
23
+ if (args[0] === '--help' || args[0] === '-h') {
24
+ io.stdout(help)
25
+ return 0
26
+ }
27
+ if (args[0] === '--version') {
28
+ io.stdout((await Bun.file(new URL('../package.json', import.meta.url)).json() as { version: string }).version)
29
+ return 0
30
+ }
31
+ if (args[0] !== 'blueprint') {
32
+ io.stderr('Usage: bunderstack blueprint [directory] [--entry <path>] [--output <path>] [--check]')
33
+ return 2
34
+ }
35
+ const options: GenerateBlueprintOptions = { directory: process.cwd() }
36
+ for (let index = 1; index < args.length; index++) {
37
+ const argument = args[index]!
38
+ if (argument === '--check') {
39
+ options.check = true
40
+ continue
41
+ }
42
+ if (argument === '--entry' || argument === '--output') {
43
+ const value = args[++index]
44
+ if (!value || value.startsWith('--')) {
45
+ io.stderr(`[bunderstack] missing value for ${argument}`)
46
+ return 2
47
+ }
48
+ if (argument === '--entry') options.entry = value
49
+ else options.output = value
50
+ continue
51
+ }
52
+ if (argument.startsWith('-')) {
53
+ io.stderr(`[bunderstack] unknown option: ${argument}`)
54
+ return 2
55
+ }
56
+ if (options.directory !== process.cwd()) {
57
+ io.stderr('[bunderstack] only one application directory is allowed')
58
+ return 2
59
+ }
60
+ options.directory = argument
61
+ }
62
+ try {
63
+ const result = await generate(options)
64
+ io.stdout(
65
+ options.check || !result.changed
66
+ ? 'bunderstack.blueprint.yaml is current'
67
+ : 'Generated bunderstack.blueprint.yaml',
68
+ )
69
+ return 0
70
+ } catch (error) {
71
+ io.stderr(error instanceof Error ? error.message : String(error))
72
+ return error instanceof BlueprintCheckError ? 1 : 1
73
+ }
74
+ }
75
+
76
+ if (import.meta.main) {
77
+ const exitCode = await runCli(process.argv.slice(2), {
78
+ stdout: (message) => console.log(message),
79
+ stderr: (message) => console.error(message),
80
+ })
81
+ process.exit(exitCode)
82
+ }
package/src/config.ts CHANGED
@@ -197,12 +197,23 @@ export function resolveConfig<TSchema extends Record<string, unknown>>(
197
197
  export function resolveRealtimeRedisUrl(
198
198
  realtime: ResolvedConfig['realtime'],
199
199
  env?: BaseEnv,
200
+ platformSource: Record<string, string | undefined> = process.env as Record<
201
+ string,
202
+ string | undefined
203
+ >,
200
204
  ): string | undefined {
205
+ const platformRedis = platformSource['REDIS_URL']
206
+ if (platformRedis) return platformRedis
207
+
208
+ const envRedis = env?.REDIS_URL
209
+ if (envRedis) return envRedis
210
+
201
211
  const fromConfig =
202
212
  typeof realtime === 'object' && realtime.redis
203
213
  ? typeof realtime.redis === 'string'
204
214
  ? realtime.redis
205
215
  : realtime.redis.url
206
216
  : undefined
207
- return fromConfig ?? env?.REDIS_URL ?? process.env.REDIS_URL ?? undefined
217
+
218
+ return fromConfig ?? undefined
208
219
  }
package/src/index.ts CHANGED
@@ -407,6 +407,7 @@ export async function createBunderstack<
407
407
  ? redisUrl
408
408
  ? createRedisRealtimeBroker({
409
409
  access: resolvedAccess,
410
+ channel: process.env.BUNDERSTACK_REALTIME_CHANNEL || undefined,
410
411
  redis: () => {
411
412
  // Redis pub/sub requires a dedicated connection (subscribe puts the client into
412
413
  // a restricted state). We use one client for commands and a second for subscribe.
@@ -487,9 +488,7 @@ export async function createBunderstack<
487
488
  },
488
489
  async getUrl(key, opts = {}) {
489
490
  const bucketName =
490
- opts.bucket ??
491
- key.split('/')[0] ??
492
- config.storage.defaultBucket
491
+ opts.bucket ?? key.split('/')[0] ?? config.storage.defaultBucket
493
492
  const adapter = registry.get(bucketName)?.adapter
494
493
  if (adapter?.presignGet) {
495
494
  return adapter.presignGet(key, {
@@ -503,7 +502,10 @@ export async function createBunderstack<
503
502
  const adapter = registry.get(bucketName)?.adapter
504
503
  if (!adapter) throw new Error(`Unknown bucket: ${bucketName}`)
505
504
  const u8 = body instanceof Uint8Array ? body : new Uint8Array(body)
506
- const buf: ArrayBuffer = u8.buffer.slice(u8.byteOffset, u8.byteOffset + u8.byteLength) as ArrayBuffer
505
+ const buf: ArrayBuffer = u8.buffer.slice(
506
+ u8.byteOffset,
507
+ u8.byteOffset + u8.byteLength,
508
+ ) as ArrayBuffer
507
509
  await adapter.upload(key, buf, contentType)
508
510
  await insertReadyFile(db, {
509
511
  fileId: key,
@@ -541,6 +543,9 @@ export async function createBunderstack<
541
543
  const startWorker = async (
542
544
  options: AppStartWorkerOptions = {},
543
545
  ): Promise<WorkerHandle> => {
546
+ if (introspect) {
547
+ return { closed: Promise.resolve(), close: async () => {} }
548
+ }
544
549
  if (!jobRunner) {
545
550
  throw new Error('[bunderstack] no queue jobs configured')
546
551
  }
@@ -562,6 +567,9 @@ export async function createBunderstack<
562
567
  const startCronScheduler = async (
563
568
  options: AppStartCronSchedulerOptions = {},
564
569
  ): Promise<LocalCronScheduler> => {
570
+ if (introspect) {
571
+ return { tick: async () => {}, close: async () => {} }
572
+ }
565
573
  const cron = Object.entries(jobsDefs ?? {}).flatMap(
566
574
  ([name, definition]) =>
567
575
  definition.kind === 'cron'
@@ -601,6 +609,7 @@ export async function createBunderstack<
601
609
  const runWorker = async (
602
610
  options: AppRunWorkerOptions = {},
603
611
  ): Promise<void> => {
612
+ if (introspect) return
604
613
  if (
605
614
  realtime.transport === 'memory' &&
606
615
  !options.allowProcessLocalRealtime
@@ -698,10 +707,11 @@ export async function createBunderstack<
698
707
  manifest: buildManifest({
699
708
  schema: options.schema,
700
709
  dialect,
710
+ migrationsDirectory: config.database.migrations,
701
711
  storage: config.storage,
702
712
  envConfig: options.env as EnvConfigInput | undefined,
713
+ emailProvider: emailProviderTag(options.email),
703
714
  realtime: Boolean(config.realtime),
704
- realtimeTransport: configuredRealtimeTransport,
705
715
  jobs: jobsDefs,
706
716
  }),
707
717
  }
@@ -742,7 +752,7 @@ export type {
742
752
  } from './config'
743
753
  export { validateEnv, createClientEnv, BunderstackEnvError } from './env'
744
754
  export type { EnvConfigInput, BaseEnv, ValidatedEnv } from './env'
745
- export { buildManifest } from './manifest'
755
+ export { buildManifest, parseManifest } from './manifest'
746
756
  export type { BunderstackManifest, ManifestEnvVar } from './manifest'
747
757
  export { createEmail } from './email'
748
758
  export type {
@@ -20,14 +20,20 @@ export async function runScheduledSlot(args: {
20
20
  slot: number
21
21
  now: number
22
22
  run: (scheduledFor: Date) => Promise<void> | void
23
+ leaseMs?: number
24
+ heartbeatIntervalMs?: number
23
25
  }): Promise<CronRunResult> {
24
26
  const { db, taskId, schedule, slot, now, run } = args
27
+ const leaseMs = args.leaseMs ?? LEASE_MS
28
+ const heartbeatIntervalMs =
29
+ args.heartbeatIntervalMs ?? Math.max(1, Math.floor(leaseMs / 4))
30
+
25
31
  if (slot % 60_000 !== 0 || !cronMatches(parseCron(schedule), slot)) {
26
32
  throw new Error('[bunderstack] cron slot does not match its schedule')
27
33
  }
28
34
 
29
35
  const t = cronRunsTableFor(db)
30
- const leaseUntil = now + LEASE_MS
36
+ const leaseUntil = now + leaseMs
31
37
  const inserted = await db
32
38
  .insert(t)
33
39
  .values({
@@ -72,20 +78,81 @@ export async function runScheduledSlot(args: {
72
78
  if (!reclaimed[0]) return { status: 'running' }
73
79
  }
74
80
 
81
+ let heartbeatTimer: Timer | undefined
82
+ const stopHeartbeat = () => {
83
+ if (heartbeatTimer) {
84
+ clearInterval(heartbeatTimer)
85
+ heartbeatTimer = undefined
86
+ }
87
+ }
88
+
89
+ heartbeatTimer = setInterval(async () => {
90
+ 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
105
+ }
106
+ }, heartbeatIntervalMs)
107
+
75
108
  try {
76
109
  await run(new Date(slot))
77
- await db
110
+
111
+ stopHeartbeat()
112
+
113
+ const updated = await db
78
114
  .update(t)
79
115
  .set({ status: 'succeeded', lockedUntil: null, finishedAt: Date.now() })
80
- .where(and(eq(t.taskId, taskId), eq(t.scheduledAt, slot)))
116
+ .where(
117
+ and(
118
+ eq(t.taskId, taskId),
119
+ eq(t.scheduledAt, slot),
120
+ eq(t.status, 'running'),
121
+ eq(t.startedAt, now),
122
+ ),
123
+ )
124
+ .returning({ taskId: t.taskId })
125
+
126
+ if (!updated[0]) {
127
+ throw new Error(
128
+ '[bunderstack] cron lease ownership was lost during execution',
129
+ )
130
+ }
131
+
81
132
  return { status: 'succeeded' }
82
133
  } catch (error) {
134
+ stopHeartbeat()
135
+
83
136
  const message = error instanceof Error ? error.message : String(error)
84
137
  await db
85
138
  .update(t)
86
- .set({ status: 'failed', lockedUntil: null, lastError: message, finishedAt: Date.now() })
87
- .where(and(eq(t.taskId, taskId), eq(t.scheduledAt, slot)))
139
+ .set({
140
+ status: 'failed',
141
+ lockedUntil: null,
142
+ lastError: message,
143
+ finishedAt: Date.now(),
144
+ })
145
+ .where(
146
+ and(
147
+ eq(t.taskId, taskId),
148
+ eq(t.scheduledAt, slot),
149
+ eq(t.status, 'running'),
150
+ eq(t.startedAt, now),
151
+ ),
152
+ )
88
153
  throw error
154
+ } finally {
155
+ stopHeartbeat()
89
156
  }
90
157
  }
91
158
 
@@ -96,6 +163,8 @@ export async function runCronSlot(args: {
96
163
  name: string
97
164
  slot: number
98
165
  now: number
166
+ leaseMs?: number
167
+ heartbeatIntervalMs?: number
99
168
  }): Promise<CronRunResult> {
100
169
  const definition = args.defs[args.name]
101
170
  if (!definition || definition.kind !== 'cron') {
@@ -107,6 +176,9 @@ export async function runCronSlot(args: {
107
176
  schedule: definition.schedule,
108
177
  slot: args.slot,
109
178
  now: args.now,
110
- run: (scheduledFor) => definition.handler({ scheduledFor }, args.ctx as never),
179
+ leaseMs: args.leaseMs,
180
+ heartbeatIntervalMs: args.heartbeatIntervalMs,
181
+ run: (scheduledFor) =>
182
+ definition.handler({ scheduledFor }, args.ctx as never),
111
183
  })
112
184
  }
package/src/manifest.ts CHANGED
@@ -1,103 +1,256 @@
1
- // src/manifest.ts — deploy-time introspection surface. Pure: consumes already
2
- // resolved config pieces, never reads process.env or touches the network.
3
- // Deployment platforms (Bunderhost) import the app declaration with
4
- // BUNDERSTACK_INTROSPECT=1 and read `app.manifest` to learn what to provision.
5
- import type { ZodType } from 'zod'
6
1
  import { getTableName, isTable } from 'drizzle-orm'
2
+ import { z, type ZodType } from 'zod'
7
3
 
8
4
  import type { Dialect } from './dialect'
9
5
  import type { EnvConfigInput } from './env'
10
6
  import type { JobsDefs } from './jobs/define'
11
- import type { RealtimeTransport } from './realtime/facade'
12
7
  import type { ResolvedBucket, ResolvedStorageBuckets } from './storage/buckets'
8
+ import { parseCron } from './jobs/cron'
13
9
  import {
14
10
  bunderstackCronRuns,
15
11
  bunderstackFiles,
12
+ bunderstackIdempotency,
16
13
  bunderstackJobs,
17
14
  } from './internal-tables'
18
15
 
19
- export type ManifestEnvVar = { key: string; required: boolean }
16
+ export type ManifestEnvVar = {
17
+ key: string
18
+ required: boolean
19
+ scope: 'server' | 'client'
20
+ }
21
+
20
22
  export type BunderstackManifest = {
21
- version: 2
22
- dialect: Dialect
23
- tables: string[]
24
- tableMap: Record<string, string>
25
- systemTables: {
26
- jobs: string
27
- files: string
28
- scheduledRuns: string
23
+ version: 3
24
+ database: {
25
+ dialect: Dialect
26
+ migrationsDirectory: string
27
+ tables: { exportName: string; physicalName: string; system: boolean }[]
29
28
  }
30
- defaultBucket: string
31
- buckets: { name: string; visibility: ResolvedBucket['visibility'] }[]
32
- realtime: boolean
33
- realtimeTransport: RealtimeTransport
34
- env: { server: ManifestEnvVar[]; client: ManifestEnvVar[] }
29
+ storage: {
30
+ defaultBucket: string
31
+ buckets: { name: string; visibility: ResolvedBucket['visibility'] }[]
32
+ }
33
+ realtime: { required: boolean }
34
+ environment: ManifestEnvVar[]
35
35
  background: {
36
36
  jobs: { name: string }[]
37
37
  cron: { name: string; schedule: string; timezone: 'UTC' }[]
38
- maintenance: { name: 'storage-sweep'; schedule: string }[]
38
+ maintenance: {
39
+ name: 'storage-sweep'
40
+ schedule: string
41
+ timezone: 'UTC'
42
+ }[]
39
43
  }
40
44
  }
41
45
 
42
- function describeTables(schema: Record<string, unknown>): Record<string, string> {
43
- return Object.fromEntries(
44
- Object.entries(schema).flatMap(([key, value]) =>
45
- isTable(value) ? [[key, getTableName(value)]] : [],
46
+ const nonEmpty = z.string().min(1)
47
+ const migrationDirectory = nonEmpty.refine(
48
+ (value) =>
49
+ 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' },
52
+ )
53
+ const cronSchedule = nonEmpty.refine(
54
+ (value) => {
55
+ try {
56
+ parseCron(value)
57
+ return true
58
+ } catch {
59
+ return false
60
+ }
61
+ },
62
+ { message: 'invalid cron schedule' },
63
+ )
64
+
65
+ const manifestSchema = z
66
+ .object({
67
+ version: z.literal(3),
68
+ database: z
69
+ .object({
70
+ dialect: z.enum(['sqlite', 'pg']),
71
+ migrationsDirectory: migrationDirectory,
72
+ tables: z.array(
73
+ z
74
+ .object({
75
+ exportName: nonEmpty,
76
+ physicalName: nonEmpty,
77
+ system: z.boolean(),
78
+ })
79
+ .strict(),
80
+ ),
81
+ })
82
+ .strict(),
83
+ storage: z
84
+ .object({
85
+ defaultBucket: nonEmpty,
86
+ buckets: z.array(
87
+ z
88
+ .object({ name: nonEmpty, visibility: z.enum(['public', 'private']) })
89
+ .strict(),
90
+ ),
91
+ })
92
+ .strict(),
93
+ realtime: z.object({ required: z.boolean() }).strict(),
94
+ environment: z.array(
95
+ z
96
+ .object({
97
+ key: nonEmpty,
98
+ required: z.boolean(),
99
+ scope: z.enum(['server', 'client']),
100
+ })
101
+ .strict(),
46
102
  ),
103
+ background: z
104
+ .object({
105
+ jobs: z.array(z.object({ name: nonEmpty }).strict()),
106
+ cron: z.array(
107
+ z
108
+ .object({
109
+ name: nonEmpty,
110
+ schedule: cronSchedule,
111
+ timezone: z.literal('UTC'),
112
+ })
113
+ .strict(),
114
+ ),
115
+ maintenance: z.array(
116
+ z
117
+ .object({
118
+ name: z.literal('storage-sweep'),
119
+ schedule: cronSchedule,
120
+ timezone: z.literal('UTC'),
121
+ })
122
+ .strict(),
123
+ ),
124
+ })
125
+ .strict(),
126
+ })
127
+ .strict()
128
+
129
+ function sortBy<T>(entries: readonly T[], key: (entry: T) => string): T[] {
130
+ return [...entries].sort((left, right) => key(left).localeCompare(key(right)))
131
+ }
132
+
133
+ function rejectDuplicates(collection: string, values: readonly string[]): void {
134
+ const seen = new Set<string>()
135
+ for (const value of values) {
136
+ if (seen.has(value)) throw new Error(`[bunderstack] duplicate ${collection} "${value}"`)
137
+ seen.add(value)
138
+ }
139
+ }
140
+
141
+ function describeTables(schema: Record<string, unknown>) {
142
+ const systemNames = new Set<string>(systemTables().map((table) => table.physicalName))
143
+ return sortBy(
144
+ Object.entries(schema).flatMap(([exportName, value]) =>
145
+ isTable(value) && !systemNames.has(getTableName(value))
146
+ ? [{ exportName, physicalName: getTableName(value), system: false }]
147
+ : [],
148
+ ),
149
+ (entry) => entry.physicalName,
47
150
  )
48
151
  }
49
152
 
50
153
  function describeSection(
51
154
  section: Record<string, ZodType> | undefined,
155
+ scope: ManifestEnvVar['scope'],
52
156
  ): ManifestEnvVar[] {
53
157
  return Object.entries(section ?? {}).map(([key, schema]) => ({
54
158
  key,
55
159
  required: !schema.safeParse(undefined).success,
160
+ scope,
56
161
  }))
57
162
  }
58
163
 
164
+ function systemTables() {
165
+ return [
166
+ { exportName: '_system.files', physicalName: getTableName(bunderstackFiles), system: true },
167
+ {
168
+ exportName: '_system.idempotency',
169
+ physicalName: getTableName(bunderstackIdempotency),
170
+ system: true,
171
+ },
172
+ { exportName: '_system.jobs', physicalName: getTableName(bunderstackJobs), system: true },
173
+ {
174
+ exportName: '_system.scheduledRuns',
175
+ physicalName: getTableName(bunderstackCronRuns),
176
+ system: true,
177
+ },
178
+ ]
179
+ }
180
+
181
+ export function parseManifest(value: unknown): BunderstackManifest {
182
+ 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))
190
+ return manifest
191
+ }
192
+
59
193
  export function buildManifest(args: {
60
194
  schema: Record<string, unknown>
61
195
  dialect: Dialect
196
+ migrationsDirectory: string
62
197
  storage: ResolvedStorageBuckets
63
198
  envConfig: EnvConfigInput | undefined
199
+ emailProvider: string | undefined
64
200
  realtime: boolean
65
- realtimeTransport: RealtimeTransport
66
201
  jobs: JobsDefs | undefined
67
202
  }): BunderstackManifest {
68
- return {
69
- version: 2,
70
- dialect: args.dialect,
71
- tables: Object.keys(args.schema),
72
- tableMap: describeTables(args.schema),
73
- systemTables: {
74
- jobs: getTableName(bunderstackJobs),
75
- files: getTableName(bunderstackFiles),
76
- scheduledRuns: getTableName(bunderstackCronRuns),
203
+ const environment = [
204
+ ...describeSection(args.envConfig?.server, 'server'),
205
+ ...describeSection(args.envConfig?.client, 'client'),
206
+ ...(args.emailProvider === 'resend'
207
+ ? [{ key: 'RESEND_API_KEY', required: true, scope: 'server' as const }]
208
+ : []),
209
+ ...(args.emailProvider === 'smtp'
210
+ ? [{ key: 'SMTP_URL', required: true, scope: 'server' as const }]
211
+ : []),
212
+ ]
213
+ rejectDuplicates('environment key', environment.map((entry) => entry.key))
214
+
215
+ return parseManifest({
216
+ version: 3,
217
+ database: {
218
+ dialect: args.dialect,
219
+ migrationsDirectory: args.migrationsDirectory,
220
+ tables: sortBy([...systemTables(), ...describeTables(args.schema)], (entry) => entry.physicalName),
77
221
  },
78
- defaultBucket: args.storage.defaultBucket,
79
- buckets: [...args.storage.buckets.values()].map((bucket) => ({
80
- name: bucket.name,
81
- visibility: bucket.visibility,
82
- })),
83
- realtime: args.realtime,
84
- realtimeTransport: args.realtimeTransport,
85
- env: {
86
- server: describeSection(args.envConfig?.server),
87
- client: describeSection(args.envConfig?.client),
222
+ storage: {
223
+ defaultBucket: args.storage.defaultBucket,
224
+ buckets: sortBy(
225
+ [...args.storage.buckets.values()].map((bucket) => ({
226
+ name: bucket.name,
227
+ visibility: bucket.visibility,
228
+ })),
229
+ (bucket) => bucket.name,
230
+ ),
88
231
  },
232
+ realtime: { required: args.realtime },
233
+ environment: sortBy(environment, (entry) => entry.key),
89
234
  background: {
90
- jobs: Object.entries(args.jobs ?? {})
91
- .filter(([, def]) => def.kind === 'job')
92
- .map(([name]) => ({ name })),
93
- cron: Object.entries(args.jobs ?? {})
94
- .filter(([, def]) => def.kind === 'cron')
95
- .map(([name, def]) => ({
96
- name,
97
- schedule: def.kind === 'cron' ? def.schedule : '',
98
- timezone: 'UTC' as const,
99
- })),
100
- maintenance: [{ name: 'storage-sweep', schedule: '0 4 * * *' }],
235
+ jobs: sortBy(
236
+ Object.entries(args.jobs ?? {})
237
+ .filter(([, def]) => def.kind === 'job')
238
+ .map(([name]) => ({ name })),
239
+ (entry) => entry.name,
240
+ ),
241
+ cron: sortBy(
242
+ Object.entries(args.jobs ?? {})
243
+ .filter(([, def]) => def.kind === 'cron')
244
+ .map(([name, def]) => ({
245
+ name,
246
+ schedule: def.kind === 'cron' ? def.schedule : '',
247
+ timezone: 'UTC' as const,
248
+ })),
249
+ (entry) => entry.name,
250
+ ),
251
+ maintenance: [
252
+ { name: 'storage-sweep', schedule: '0 4 * * *', timezone: 'UTC' as const },
253
+ ],
101
254
  },
102
- }
255
+ })
103
256
  }
package/src/provision.ts CHANGED
@@ -105,6 +105,8 @@ export async function provision(
105
105
  app: object,
106
106
  options?: { force?: boolean },
107
107
  ): Promise<void> {
108
+ if (process.env.BUNDERSTACK_INTROSPECT === '1') return
109
+
108
110
  const internals = (app as WithProvisionInternals)[PROVISION_INTERNALS]
109
111
  if (!internals) {
110
112
  throw new Error(
@@ -125,7 +125,8 @@ export function createRedisRealtimeBroker(opts: {
125
125
  if (typeof entry.get === 'function') return false
126
126
  if (!checkAccessSync(entry.get, ctx, entry.ownerColumn).allowed)
127
127
  return false
128
- if (entry.readScope && !rowMatchesScope(record, entry.readScope(ctx))) return false
128
+ if (entry.readScope && !rowMatchesScope(record, entry.readScope(ctx)))
129
+ return false
129
130
  return true
130
131
  }
131
132
 
@@ -140,7 +141,10 @@ export function createRedisRealtimeBroker(opts: {
140
141
  let closed = false
141
142
 
142
143
  const start = () => {
143
- if (closed) return Promise.reject(new Error('[bunderstack] realtime broker is closed'))
144
+ if (closed)
145
+ return Promise.reject(
146
+ new Error('[bunderstack] realtime broker is closed'),
147
+ )
144
148
  started ??= getRedis()
145
149
  .subscribe(channel, (message) => {
146
150
  const evt = parseWireEvent(message)