bunderstack 0.2.0 → 0.3.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
@@ -27,6 +27,39 @@ Bun.serve({ fetch: app.handler })
27
27
  Full documentation and examples:
28
28
  [github.com/kirill-dev-pro/bunderstack](https://github.com/kirill-dev-pro/bunderstack)
29
29
 
30
+ ## Platform deployment contract
31
+
32
+ Deployment platforms (like Bunderhost) integrate with any bunderstack app
33
+ through env vars alone — no code changes required.
34
+
35
+ ### Overrides (beat code-level config)
36
+
37
+ | Var | Effect |
38
+ | --- | --- |
39
+ | `BUNDERSTACK_DATABASE_URL` | Database URL; wins over `database.url` in code |
40
+ | `BUNDERSTACK_DATABASE_AUTH_TOKEN` | Auth token for the database |
41
+ | `BUNDERSTACK_S3_ENDPOINT` | Forces ALL buckets onto this S3 backend (code-level `local`/per-bucket `s3` blocks are ignored) |
42
+ | `BUNDERSTACK_S3_BUCKET` | Physical bucket name (logical buckets become key prefixes) |
43
+ | `BUNDERSTACK_S3_ACCESS_KEY_ID` / `BUNDERSTACK_S3_SECRET_ACCESS_KEY` | Credentials |
44
+ | `BUNDERSTACK_S3_REGION` | Region (default `auto`) |
45
+ | `BUNDERSTACK_S3_PUBLIC_URL` | Public base URL for `visibility: 'public'` buckets |
46
+
47
+ Plain `DATABASE_URL` / `S3_*` vars keep their usual role: fallbacks that
48
+ code-level config wins over.
49
+
50
+ ### Introspection
51
+
52
+ Set `BUNDERSTACK_INTROSPECT=1` and import the app declaration: the boot is
53
+ guaranteed offline (in-memory database, no Redis) and missing user env vars
54
+ don't throw. Then read `app.manifest`:
55
+
56
+ ```ts
57
+ process.env.BUNDERSTACK_INTROSPECT = '1'
58
+ const { app } = await import('./src/bunderstack')
59
+ console.log(JSON.stringify(app.manifest))
60
+ // { dialect, tables, defaultBucket, buckets, realtime, env: { server, client } }
61
+ ```
62
+
30
63
  ## Shipping TypeScript source
31
64
 
32
65
  This package publishes raw TypeScript (`exports` point at `.ts` files). Bun
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bunderstack",
3
- "version": "0.2.0",
3
+ "version": "0.3.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",
package/src/config.ts CHANGED
@@ -118,6 +118,12 @@ export type ResolvedConfig = {
118
118
  export function resolveConfig<TSchema extends Record<string, unknown>>(
119
119
  options: BunderstackConfig<TSchema>,
120
120
  env?: BaseEnv,
121
+ // Platform-injected overrides (Bunderhost & co.) beat code-level config so
122
+ // apps with hardcoded local urls deploy unchanged.
123
+ platformSource: Record<string, string | undefined> = process.env as Record<
124
+ string,
125
+ string | undefined
126
+ >,
121
127
  ): ResolvedConfig {
122
128
  const parsed = BunderstackOptionsSchema.parse(options)
123
129
  // Self-validate when the caller didn't pass a pre-validated env, so
@@ -127,8 +133,14 @@ export function resolveConfig<TSchema extends Record<string, unknown>>(
127
133
 
128
134
  return {
129
135
  database: {
130
- url: parsed.database?.url ?? resolvedEnv.DATABASE_URL,
131
- authToken: parsed.database?.authToken ?? resolvedEnv.DATABASE_AUTH_TOKEN,
136
+ url:
137
+ platformSource['BUNDERSTACK_DATABASE_URL'] ??
138
+ parsed.database?.url ??
139
+ resolvedEnv.DATABASE_URL,
140
+ authToken:
141
+ platformSource['BUNDERSTACK_DATABASE_AUTH_TOKEN'] ??
142
+ parsed.database?.authToken ??
143
+ resolvedEnv.DATABASE_AUTH_TOKEN,
132
144
  migrations: parsed.database?.migrations ?? './migrations',
133
145
  },
134
146
  auth: (() => {
@@ -138,7 +150,7 @@ export function resolveConfig<TSchema extends Record<string, unknown>>(
138
150
  secret: authInput.secret ?? resolvedEnv.AUTH_SECRET,
139
151
  }
140
152
  })(),
141
- storage: resolveBuckets(options.storage),
153
+ storage: resolveBuckets(options.storage, platformSource),
142
154
  realtime: parsed.realtime,
143
155
  }
144
156
  }
package/src/env.ts CHANGED
@@ -120,7 +120,10 @@ export function validateEnv<TEnv extends EnvConfigInput | undefined>(
120
120
  validateSection(envConfig?.server, 'server', source, issues, userVars)
121
121
  validateSection(envConfig?.client, 'client', source, issues, userVars)
122
122
 
123
- if (issues.length > 0) throw new BunderstackEnvError(issues)
123
+ // Introspection (Bunderhost builder) imports the app declaration to read
124
+ // its manifest; missing user env must not kill the boot there.
125
+ const lenient = source.BUNDERSTACK_INTROSPECT === '1'
126
+ if (issues.length > 0 && !lenient) throw new BunderstackEnvError(issues)
124
127
  return { ...base, ...userVars } as ValidatedEnv<TEnv>
125
128
  }
126
129
 
package/src/index.ts CHANGED
@@ -16,6 +16,7 @@ import { resolveRealtimeRedisUrl } from './config'
16
16
  import { detectDialect } from './dialect'
17
17
  import { createEmail, emailProviderTag, type EmailFacade } from './email'
18
18
  import { validateEnv, type EnvConfigInput, type ValidatedEnv } from './env'
19
+ import { buildManifest, type BunderstackManifest } from './manifest'
19
20
  import { createTRPC, type BunderstackTRPC } from './trpc'
20
21
  import { buildCrudRouter } from './crud'
21
22
  import { createDb } from './db'
@@ -84,6 +85,8 @@ export type BunderstackApp<
84
85
  env: ValidatedEnv<TEnv>
85
86
  /** Email facade; always present — send() throws when email isn't configured. */
86
87
  email: EmailFacade
88
+ /** Deploy-time introspection: what this app needs provisioned. */
89
+ manifest: BunderstackManifest
87
90
  /**
88
91
  * Type-only carrier for client inference (`createClient<typeof app>()`).
89
92
  * Never assigned at runtime.
@@ -150,6 +153,15 @@ export async function createBunderstack<
150
153
  dialect === 'pg' ? 'file:./data.pglite' : 'file:./data.db',
151
154
  })
152
155
  const config = resolveConfig(options, env)
156
+ // Introspection mode (BUNDERSTACK_INTROSPECT=1): deployment platforms import
157
+ // the app declaration only to read `app.manifest`. The boot must never touch
158
+ // the outside world — force an in-memory db (':memory:' is valid for both
159
+ // dialects) and skip Redis below. Env validation is already lenient (env.ts).
160
+ const introspect = process.env.BUNDERSTACK_INTROSPECT === '1'
161
+ if (introspect) {
162
+ config.database.url = ':memory:'
163
+ config.database.authToken = undefined
164
+ }
153
165
  const email = createEmail(options.email, { env })
154
166
  // Merge bunderstack's internal tables (file-meta, idempotency) into the
155
167
  // schema used for the db client + provisioning. CRUD/access stay on the USER
@@ -180,9 +192,10 @@ export async function createBunderstack<
180
192
  )
181
193
  const realtimeBufferSize =
182
194
  typeof config.realtime === 'object' ? config.realtime.bufferSize : undefined
183
- const redisUrl = config.realtime
184
- ? resolveRealtimeRedisUrl(config.realtime, env)
185
- : undefined
195
+ const redisUrl =
196
+ config.realtime && !introspect
197
+ ? resolveRealtimeRedisUrl(config.realtime, env)
198
+ : undefined
186
199
  const broker = config.realtime
187
200
  ? redisUrl
188
201
  ? createRedisRealtimeBroker({
@@ -302,6 +315,13 @@ export async function createBunderstack<
302
315
  env,
303
316
  email,
304
317
  trpcRouter,
318
+ manifest: buildManifest({
319
+ schema: options.schema,
320
+ dialect,
321
+ storage: config.storage,
322
+ envConfig: options.env as EnvConfigInput | undefined,
323
+ realtime: Boolean(config.realtime),
324
+ }),
305
325
  }
306
326
 
307
327
  // Hidden handle for the optional `bunderstack/provision` entry. Kept off the
@@ -328,6 +348,8 @@ export type {
328
348
  } from './config'
329
349
  export { validateEnv, createClientEnv, BunderstackEnvError } from './env'
330
350
  export type { EnvConfigInput, BaseEnv, ValidatedEnv } from './env'
351
+ export { buildManifest } from './manifest'
352
+ export type { BunderstackManifest, ManifestEnvVar } from './manifest'
331
353
  export { createEmail } from './email'
332
354
  export type {
333
355
  EmailMessage,
@@ -0,0 +1,52 @@
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
+
7
+ import type { Dialect } from './dialect'
8
+ import type { EnvConfigInput } from './env'
9
+ import type { ResolvedBucket, ResolvedStorageBuckets } from './storage/buckets'
10
+
11
+ export type ManifestEnvVar = { key: string; required: boolean }
12
+
13
+ export type BunderstackManifest = {
14
+ dialect: Dialect
15
+ tables: string[]
16
+ defaultBucket: string
17
+ buckets: { name: string; visibility: ResolvedBucket['visibility'] }[]
18
+ realtime: boolean
19
+ env: { server: ManifestEnvVar[]; client: ManifestEnvVar[] }
20
+ }
21
+
22
+ function describeSection(
23
+ section: Record<string, ZodType> | undefined,
24
+ ): ManifestEnvVar[] {
25
+ return Object.entries(section ?? {}).map(([key, schema]) => ({
26
+ key,
27
+ required: !schema.safeParse(undefined).success,
28
+ }))
29
+ }
30
+
31
+ export function buildManifest(args: {
32
+ schema: Record<string, unknown>
33
+ dialect: Dialect
34
+ storage: ResolvedStorageBuckets
35
+ envConfig: EnvConfigInput | undefined
36
+ realtime: boolean
37
+ }): BunderstackManifest {
38
+ return {
39
+ dialect: args.dialect,
40
+ tables: Object.keys(args.schema),
41
+ defaultBucket: args.storage.defaultBucket,
42
+ buckets: [...args.storage.buckets.values()].map((bucket) => ({
43
+ name: bucket.name,
44
+ visibility: bucket.visibility,
45
+ })),
46
+ realtime: args.realtime,
47
+ env: {
48
+ server: describeSection(args.envConfig?.server),
49
+ client: describeSection(args.envConfig?.client),
50
+ },
51
+ }
52
+ }
@@ -108,6 +108,32 @@ export function parseSize(value: string | number): number {
108
108
  return Math.floor(num * multiplier)
109
109
  }
110
110
 
111
+ // ---------------------------------------------------------------------------
112
+ // Platform override (Bunderhost & co.)
113
+ // ---------------------------------------------------------------------------
114
+
115
+ /**
116
+ * A deployment platform that injects BUNDERSTACK_S3_ENDPOINT forces every
117
+ * bucket onto that backend — code-level `local`/per-bucket `s3` blocks are
118
+ * ignored so apps deploy unchanged. Logical buckets already prefix object
119
+ * keys ("<bucket>/<uuid>"), so one physical bucket per environment suffices.
120
+ */
121
+ function platformS3Backend(
122
+ env: Record<string, string | undefined>,
123
+ ): ResolvedBackend | undefined {
124
+ const endpoint = env['BUNDERSTACK_S3_ENDPOINT']
125
+ if (!endpoint) return undefined
126
+ return {
127
+ type: 's3',
128
+ bucket: env['BUNDERSTACK_S3_BUCKET'] ?? '',
129
+ region: env['BUNDERSTACK_S3_REGION'] ?? 'auto',
130
+ endpoint,
131
+ accessKeyId: env['BUNDERSTACK_S3_ACCESS_KEY_ID'] ?? '',
132
+ secretAccessKey: env['BUNDERSTACK_S3_SECRET_ACCESS_KEY'] ?? '',
133
+ publicUrl: env['BUNDERSTACK_S3_PUBLIC_URL'],
134
+ }
135
+ }
136
+
111
137
  // ---------------------------------------------------------------------------
112
138
  // Shared backend resolution
113
139
  // ---------------------------------------------------------------------------
@@ -149,6 +175,9 @@ function resolveBucketBackend(
149
175
  sharedBackend: ResolvedBackend,
150
176
  env: Record<string, string | undefined>,
151
177
  ): ResolvedBackend {
178
+ // Platform override active → sharedBackend IS the platform backend and
179
+ // code-level per-bucket backends are ignored.
180
+ if (env['BUNDERSTACK_S3_ENDPOINT']) return sharedBackend
152
181
  if ('s3' in bucketInput && bucketInput.s3 !== undefined) {
153
182
  const block = bucketInput.s3
154
183
  return {
@@ -232,7 +261,7 @@ export function resolveBuckets(
232
261
  input: StorageConfigInput | undefined,
233
262
  env: Record<string, string | undefined> = process.env,
234
263
  ): ResolvedStorageBuckets {
235
- const sharedBackend = resolveSharedBackend(input, env)
264
+ const sharedBackend = platformS3Backend(env) ?? resolveSharedBackend(input, env)
236
265
  const bucketsInput = input?.buckets
237
266
 
238
267
  const declaredNames = bucketsInput ? Object.keys(bucketsInput) : []