bunderstack 0.1.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.1.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",
@@ -33,8 +33,11 @@
33
33
  "exports": {
34
34
  ".": "./src/index.ts",
35
35
  "./access": "./src/access.ts",
36
+ "./provision": "./src/provision.ts",
36
37
  "./schema": "./src/schema-export.ts",
38
+ "./schema/pg": "./src/schema-export-pg.ts",
37
39
  "./typeid": "./src/typeid.ts",
40
+ "./typeid/pg": "./src/typeid-pg.ts",
38
41
  "./env": "./src/env.ts",
39
42
  "./trpc": "./src/trpc.ts"
40
43
  },
@@ -45,25 +48,42 @@
45
48
  "db:migrate": "drizzle-kit migrate"
46
49
  },
47
50
  "dependencies": {
48
- "@libsql/client": "^0.14.0",
49
51
  "@trpc/server": "^11.0.0",
50
52
  "better-auth": "^1.0.0",
51
- "drizzle-orm": "^0.45.0",
52
53
  "hono": "^4.0.0",
53
54
  "superjson": "^2.2.0",
54
55
  "zod": "^4.4.3"
55
56
  },
57
+ "devDependencies": {
58
+ "@electric-sql/pglite": ">=0.3.0",
59
+ "@libsql/client": ">=0.14.0",
60
+ "drizzle-kit": "^0.30.0",
61
+ "drizzle-orm": "^0.45.0"
62
+ },
56
63
  "peerDependencies": {
64
+ "@electric-sql/pglite": ">=0.3.0",
65
+ "@libsql/client": ">=0.14.0",
57
66
  "drizzle-kit": "^0.30.0",
67
+ "drizzle-orm": "^0.45.0",
58
68
  "nodemailer": "^6",
69
+ "postgres": ">=3.4.0",
59
70
  "typescript": "^5"
60
71
  },
61
72
  "peerDependenciesMeta": {
73
+ "@electric-sql/pglite": {
74
+ "optional": true
75
+ },
76
+ "@libsql/client": {
77
+ "optional": true
78
+ },
62
79
  "drizzle-kit": {
63
80
  "optional": true
64
81
  },
65
82
  "nodemailer": {
66
83
  "optional": true
84
+ },
85
+ "postgres": {
86
+ "optional": true
67
87
  }
68
88
  }
69
89
  }
package/src/auth.ts CHANGED
@@ -1,20 +1,18 @@
1
- import type { LibSQLDatabase } from 'drizzle-orm/libsql'
2
-
3
1
  // src/auth.ts
4
2
  import { betterAuth } from 'better-auth'
5
3
  import { drizzleAdapter } from 'better-auth/adapters/drizzle'
6
4
 
7
5
  import type { BetterAuthConfig } from './config'
8
6
  import type { AuthSessionResolver } from './access'
7
+ import type { AnyDb, Dialect } from './dialect'
9
8
  import type { EmailFacade } from './email'
10
9
 
11
- export function createAuth(
12
- db: LibSQLDatabase<Record<string, unknown>>,
13
- cfg: BetterAuthConfig,
14
- ) {
10
+ export function createAuth(db: AnyDb, cfg: BetterAuthConfig, dialect: Dialect) {
15
11
  return betterAuth({
16
12
  ...cfg,
17
- database: drizzleAdapter(db, { provider: 'sqlite' }),
13
+ database: drizzleAdapter(db as Parameters<typeof drizzleAdapter>[0], {
14
+ provider: dialect === 'pg' ? 'pg' : 'sqlite',
15
+ }),
18
16
  })
19
17
  }
20
18
 
package/src/config.ts CHANGED
@@ -24,7 +24,11 @@ export const BunderstackOptionsSchema = z.object({
24
24
  schema: z.record(z.string(), z.unknown()),
25
25
  access: z.record(z.string(), z.any()).optional(),
26
26
  database: z
27
- .object({ url: z.string().optional(), authToken: z.string().optional() })
27
+ .object({
28
+ url: z.string().optional(),
29
+ authToken: z.string().optional(),
30
+ migrations: z.string().optional(),
31
+ })
28
32
  .optional(),
29
33
  auth: z.record(z.string(), z.unknown()).optional(),
30
34
  // Loose: bucket access/scope hold functions that can't survive strict zod
@@ -99,7 +103,7 @@ export type BunderstackConfig<
99
103
  }
100
104
 
101
105
  export type ResolvedConfig = {
102
- database: { url: string; authToken?: string }
106
+ database: { url: string; authToken?: string; migrations: string }
103
107
  auth: BetterAuthConfig
104
108
  storage: ResolvedStorageBuckets
105
109
  realtime?:
@@ -114,6 +118,12 @@ export type ResolvedConfig = {
114
118
  export function resolveConfig<TSchema extends Record<string, unknown>>(
115
119
  options: BunderstackConfig<TSchema>,
116
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
+ >,
117
127
  ): ResolvedConfig {
118
128
  const parsed = BunderstackOptionsSchema.parse(options)
119
129
  // Self-validate when the caller didn't pass a pre-validated env, so
@@ -123,8 +133,15 @@ export function resolveConfig<TSchema extends Record<string, unknown>>(
123
133
 
124
134
  return {
125
135
  database: {
126
- url: parsed.database?.url ?? resolvedEnv.DATABASE_URL,
127
- 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,
144
+ migrations: parsed.database?.migrations ?? './migrations',
128
145
  },
129
146
  auth: (() => {
130
147
  const authInput = options.auth ?? {}
@@ -133,7 +150,7 @@ export function resolveConfig<TSchema extends Record<string, unknown>>(
133
150
  secret: authInput.secret ?? resolvedEnv.AUTH_SECRET,
134
151
  }
135
152
  })(),
136
- storage: resolveBuckets(options.storage),
153
+ storage: resolveBuckets(options.storage, platformSource),
137
154
  realtime: parsed.realtime,
138
155
  }
139
156
  }
package/src/crud.ts CHANGED
@@ -1,8 +1,7 @@
1
- import type { LibSQLDatabase } from 'drizzle-orm/libsql'
2
-
3
1
  import { eq, getTableColumns, getTableName, isTable } from 'drizzle-orm'
4
2
  import { Hono } from 'hono'
5
3
 
4
+ import type { AnyDb } from './dialect'
6
5
  import type { RealtimeBroker } from './realtime/index'
7
6
 
8
7
  import {
@@ -61,7 +60,7 @@ async function enforce(
61
60
 
62
61
  export function buildCrudRouter<TSchema extends Record<string, unknown>>(
63
62
  schema: TSchema,
64
- db: LibSQLDatabase<TSchema>,
63
+ db: AnyDb,
65
64
  options: CrudRouterOptions,
66
65
  ): Hono {
67
66
  const router = new Hono()
package/src/db.ts CHANGED
@@ -1,10 +1,103 @@
1
- import { createClient } from '@libsql/client'
2
- import { drizzle } from 'drizzle-orm/libsql'
1
+ // src/db.ts dialect/driver dispatch. Every driver module loads via dynamic
2
+ // import so the driver packages stay optional peers; the ignore comments keep
3
+ // bundlers (vite/nitro, webpack) from resolving them at build time.
4
+ import type { LibSQLDatabase } from 'drizzle-orm/libsql'
5
+ import type { PgDatabase, PgQueryResultHKT, PgTable } from 'drizzle-orm/pg-core'
3
6
 
4
- export function createDb<TSchema extends Record<string, unknown>>(
7
+ import { mkdir } from 'node:fs/promises'
8
+
9
+ import type { Dialect } from './dialect'
10
+
11
+ export type Driver = 'libsql' | 'pglite' | 'bun-sql' | 'postgres-js'
12
+
13
+ /** Per-dialect public db type, computed from the schema's table brands. */
14
+ export type DbFor<TSchema extends Record<string, unknown>> = [
15
+ Extract<TSchema[keyof TSchema], PgTable>,
16
+ ] extends [never]
17
+ ? LibSQLDatabase<TSchema>
18
+ : PgDatabase<PgQueryResultHKT, TSchema>
19
+
20
+ const PG_SERVER_RE = /^postgres(ql)?:\/\//
21
+ const LIBSQL_REMOTE_RE = /^(libsql|wss?|https?):\/\//
22
+
23
+ /** Exported for testing the missing-package error wrapping in isolation. */
24
+ export async function importDriver<T>(specifier: string, hint: string): Promise<T> {
25
+ try {
26
+ return (await import(
27
+ /* @vite-ignore */ /* webpackIgnore: true */ specifier
28
+ )) as T
29
+ } catch (cause) {
30
+ throw new Error(`[bunderstack] ${hint}`, { cause })
31
+ }
32
+ }
33
+
34
+ export async function createDb<TSchema extends Record<string, unknown>>(
5
35
  schema: TSchema,
6
- cfg: { url: string; authToken?: string },
7
- ) {
8
- const client = createClient({ url: cfg.url, authToken: cfg.authToken })
9
- return drizzle(client, { schema })
36
+ cfg: { url: string; authToken?: string; dialect: Dialect },
37
+ ): Promise<{ db: DbFor<TSchema>; driver: Driver }> {
38
+ if (cfg.dialect === 'sqlite') {
39
+ if (PG_SERVER_RE.test(cfg.url)) {
40
+ throw new Error(
41
+ '[bunderstack] DATABASE_URL is a Postgres URL but the schema uses sqliteTable. ' +
42
+ 'Define the schema with drizzle-orm/pg-core, or point DATABASE_URL at a SQLite database.',
43
+ )
44
+ }
45
+ const { drizzle } = await importDriver<typeof import('drizzle-orm/libsql')>(
46
+ 'drizzle-orm/libsql',
47
+ 'SQLite support requires @libsql/client, which is not installed.\n' +
48
+ ' Run `bun add @libsql/client`.',
49
+ )
50
+ const db = drizzle({
51
+ connection: { url: cfg.url, authToken: cfg.authToken },
52
+ schema,
53
+ })
54
+ return { db: db as unknown as DbFor<TSchema>, driver: 'libsql' }
55
+ }
56
+
57
+ if (LIBSQL_REMOTE_RE.test(cfg.url)) {
58
+ throw new Error(
59
+ '[bunderstack] DATABASE_URL looks like a libsql/Turso URL but the schema uses pgTable. ' +
60
+ 'Set DATABASE_URL=postgres://… (or leave it unset for local PGlite).',
61
+ )
62
+ }
63
+
64
+ if (PG_SERVER_RE.test(cfg.url)) {
65
+ if (typeof Bun !== 'undefined') {
66
+ const { drizzle } = await import(
67
+ /* @vite-ignore */ /* webpackIgnore: true */ 'drizzle-orm/bun-sql'
68
+ )
69
+ return {
70
+ db: drizzle(cfg.url, { schema }) as unknown as DbFor<TSchema>,
71
+ driver: 'bun-sql',
72
+ }
73
+ }
74
+ const { drizzle } = await importDriver<
75
+ typeof import('drizzle-orm/postgres-js')
76
+ >(
77
+ 'drizzle-orm/postgres-js',
78
+ 'Postgres on Node requires the `postgres` driver, which is not installed.\n' +
79
+ ' Run `npm install postgres`. (Under Bun the built-in Bun.sql is used instead.)',
80
+ )
81
+ return {
82
+ db: drizzle(cfg.url, { schema }) as unknown as DbFor<TSchema>,
83
+ driver: 'postgres-js',
84
+ }
85
+ }
86
+
87
+ // Local PGlite: `file:<dir>`, a bare path, `:memory:`, or `memory://`.
88
+ const raw = cfg.url.startsWith('file:') ? cfg.url.slice('file:'.length) : cfg.url
89
+ const dataDir = raw === ':memory:' ? 'memory://' : raw
90
+ if (!dataDir.startsWith('memory://')) {
91
+ await mkdir(dataDir, { recursive: true })
92
+ }
93
+ const { drizzle } = await importDriver<typeof import('drizzle-orm/pglite')>(
94
+ 'drizzle-orm/pglite',
95
+ 'Local Postgres development requires PGlite, which is not installed.\n' +
96
+ ' Run `bun add -d @electric-sql/pglite` — bunderstack runs an embedded Postgres in ./data.pglite.\n' +
97
+ ' In production set DATABASE_URL=postgres://… (PGlite is not needed there).',
98
+ )
99
+ return {
100
+ db: drizzle(dataDir, { schema }) as unknown as DbFor<TSchema>,
101
+ driver: 'pglite',
102
+ }
10
103
  }
package/src/dialect.ts ADDED
@@ -0,0 +1,38 @@
1
+ // src/dialect.ts — schema-driven dialect detection. Imports only dialect-core
2
+ // drizzle entrypoints (no drivers), safe in every module graph.
3
+ import { is } from 'drizzle-orm'
4
+ import { PgTable } from 'drizzle-orm/pg-core'
5
+ import { SQLiteTable } from 'drizzle-orm/sqlite-core'
6
+
7
+ export type Dialect = 'sqlite' | 'pg'
8
+
9
+ /**
10
+ * Minimal structural view of a drizzle db shared by both dialects. Internal
11
+ * modules run dynamic tables (Record<string, unknown> schemas) where drizzle's
12
+ * generics add no safety, so they accept this instead of a per-dialect union.
13
+ * The public surface (`app.db`, tRPC ctx) keeps full per-dialect typing via
14
+ * `DbFor` in db.ts.
15
+ */
16
+ export type AnyDb = {
17
+ select: (...args: any[]) => any
18
+ insert: (...args: any[]) => any
19
+ update: (...args: any[]) => any
20
+ delete: (...args: any[]) => any
21
+ }
22
+
23
+ /** Classify a schema by its table brands. Mixed dialects are a config error. */
24
+ export function detectDialect(schema: Record<string, unknown>): Dialect {
25
+ let pgKey: string | undefined
26
+ let sqliteKey: string | undefined
27
+ for (const [key, value] of Object.entries(schema)) {
28
+ if (is(value, PgTable)) pgKey ??= key
29
+ else if (is(value, SQLiteTable)) sqliteKey ??= key
30
+ }
31
+ if (pgKey !== undefined && sqliteKey !== undefined) {
32
+ throw new Error(
33
+ `[bunderstack] schema mixes dialects: "${pgKey}" is a Postgres table while "${sqliteKey}" is a SQLite table. ` +
34
+ 'Define every table with the same dialect (drizzle-orm/pg-core or drizzle-orm/sqlite-core).',
35
+ )
36
+ }
37
+ return pgKey !== undefined ? 'pg' : 'sqlite'
38
+ }
package/src/env.ts CHANGED
@@ -49,6 +49,8 @@ export type ValidateEnvOptions = {
49
49
  emailProvider?: string
50
50
  /** Value source; defaults to process.env. Tests pass this explicitly. */
51
51
  source?: Record<string, string | undefined>
52
+ /** Dialect-aware DATABASE_URL fallback; createBunderstack passes it. */
53
+ defaultDatabaseUrl?: string
52
54
  }
53
55
 
54
56
  const DEV_AUTH_SECRET = 'dev-secret-change-in-prod'
@@ -96,7 +98,8 @@ export function validateEnv<TEnv extends EnvConfigInput | undefined>(
96
98
 
97
99
  const base: BaseEnv = {
98
100
  NODE_ENV: source.NODE_ENV,
99
- DATABASE_URL: source.DATABASE_URL ?? 'file:./data.db',
101
+ DATABASE_URL:
102
+ source.DATABASE_URL ?? options.defaultDatabaseUrl ?? 'file:./data.db',
100
103
  DATABASE_AUTH_TOKEN: source.DATABASE_AUTH_TOKEN,
101
104
  AUTH_SECRET: source.AUTH_SECRET ?? DEV_AUTH_SECRET,
102
105
  REDIS_URL: source.REDIS_URL,
@@ -117,7 +120,10 @@ export function validateEnv<TEnv extends EnvConfigInput | undefined>(
117
120
  validateSection(envConfig?.server, 'server', source, issues, userVars)
118
121
  validateSection(envConfig?.client, 'client', source, issues, userVars)
119
122
 
120
- 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)
121
127
  return { ...base, ...userVars } as ValidatedEnv<TEnv>
122
128
  }
123
129
 
@@ -1,9 +1,9 @@
1
- import type { LibSQLDatabase } from 'drizzle-orm/libsql'
2
-
3
1
  import { and, eq, lt } from 'drizzle-orm'
4
2
  import { createHash } from 'node:crypto'
5
3
 
6
- import { bunderstackIdempotency } from './internal-tables'
4
+ import type { AnyDb } from './dialect'
5
+
6
+ import { idempotencyTableFor } from './internal-tables'
7
7
 
8
8
  export type IdempotencyConfig = {
9
9
  ttlMs?: number
@@ -21,34 +21,28 @@ export type IdempotencyLookup =
21
21
  | { type: 'proceed' }
22
22
 
23
23
  export async function lookupIdempotency(
24
- db: LibSQLDatabase<Record<string, unknown>>,
24
+ db: AnyDb,
25
25
  tableName: string,
26
26
  key: string,
27
27
  body: string,
28
28
  config: IdempotencyConfig,
29
29
  ): Promise<IdempotencyLookup> {
30
+ const t = idempotencyTableFor(db)
30
31
  const now = Date.now()
31
32
 
32
33
  // TTL sweep: drop expired rows before reading.
33
- await db
34
- .delete(bunderstackIdempotency)
35
- .where(lt(bunderstackIdempotency.expiresAt, now))
34
+ await db.delete(t).where(lt(t.expiresAt, now))
36
35
 
37
36
  const bodyHash = hashBody(body)
38
37
  const rows = await db
39
38
  .select({
40
- bodyHash: bunderstackIdempotency.bodyHash,
41
- status: bunderstackIdempotency.status,
42
- response: bunderstackIdempotency.response,
43
- expiresAt: bunderstackIdempotency.expiresAt,
39
+ bodyHash: t.bodyHash,
40
+ status: t.status,
41
+ response: t.response,
42
+ expiresAt: t.expiresAt,
44
43
  })
45
- .from(bunderstackIdempotency)
46
- .where(
47
- and(
48
- eq(bunderstackIdempotency.key, key),
49
- eq(bunderstackIdempotency.tableName, tableName),
50
- ),
51
- )
44
+ .from(t)
45
+ .where(and(eq(t.key, key), eq(t.tableName, tableName)))
52
46
  .limit(1)
53
47
 
54
48
  const row = rows[0]
@@ -64,7 +58,7 @@ export async function lookupIdempotency(
64
58
  }
65
59
 
66
60
  export async function storeIdempotency(
67
- db: LibSQLDatabase<Record<string, unknown>>,
61
+ db: AnyDb,
68
62
  tableName: string,
69
63
  key: string,
70
64
  body: string,
@@ -72,13 +66,14 @@ export async function storeIdempotency(
72
66
  response: unknown,
73
67
  config: IdempotencyConfig,
74
68
  ): Promise<void> {
69
+ const t = idempotencyTableFor(db)
75
70
  const ttlMs = config.ttlMs ?? DEFAULT_TTL_MS
76
71
  const expiresAt = Date.now() + ttlMs
77
72
  const bodyHash = hashBody(body)
78
73
  const responseText = JSON.stringify(response)
79
74
 
80
75
  await db
81
- .insert(bunderstackIdempotency)
76
+ .insert(t)
82
77
  .values({
83
78
  key,
84
79
  tableName,
@@ -88,7 +83,7 @@ export async function storeIdempotency(
88
83
  expiresAt,
89
84
  })
90
85
  .onConflictDoUpdate({
91
- target: [bunderstackIdempotency.key, bunderstackIdempotency.tableName],
86
+ target: [t.key, t.tableName],
92
87
  set: { bodyHash, status, response: responseText, expiresAt },
93
88
  })
94
89
  }