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/src/index.ts CHANGED
@@ -1,26 +1,31 @@
1
1
  // src/index.ts
2
2
  import type { AnyRouter } from '@trpc/server'
3
- import type { LibSQLDatabase } from 'drizzle-orm/libsql'
4
3
  import type { Hono as HonoType } from 'hono'
5
4
 
6
5
  import { fetchRequestHandler } from '@trpc/server/adapters/fetch'
7
6
 
8
7
  import type { StorageAdapter } from './storage/index'
9
8
  import type { TableAccessInput } from './access'
9
+ import type { DbFor } from './db'
10
10
  import type { StorageConfigInput } from './storage/buckets'
11
11
 
12
12
  import { resolveAccessUser, validateAndResolveAccess } from './access'
13
13
  import { createAuth, toAuthSessionResolver, withEmailAuthDefaults } from './auth'
14
14
  import { resolveConfig, type BunderstackConfig } from './config'
15
15
  import { resolveRealtimeRedisUrl } from './config'
16
+ import { detectDialect } from './dialect'
16
17
  import { createEmail, emailProviderTag, type EmailFacade } from './email'
17
18
  import { validateEnv, type EnvConfigInput, type ValidatedEnv } from './env'
19
+ import { buildManifest, type BunderstackManifest } from './manifest'
18
20
  import { createTRPC, type BunderstackTRPC } from './trpc'
19
21
  import { buildCrudRouter } from './crud'
20
22
  import { createDb } from './db'
21
23
  import { buildHandler } from './handler'
22
24
  import { withInternalTables } from './internal-tables'
23
- import { provisionSchema } from './provision'
25
+ import {
26
+ PROVISION_INTERNALS,
27
+ type WithProvisionInternals,
28
+ } from './provision-internals'
24
29
  import { createRealtimeBroker, buildRealtimeRouter } from './realtime/index'
25
30
  import { createRedisRealtimeBroker } from './realtime/redis'
26
31
  import { deleteFileWithDerivatives } from './storage/delete'
@@ -70,7 +75,7 @@ export type BunderstackApp<
70
75
  TRouter = undefined,
71
76
  > = {
72
77
  handler: (req: Request) => Promise<Response>
73
- db: LibSQLDatabase<TSchema>
78
+ db: DbFor<TSchema>
74
79
  auth: AuthInstance
75
80
  storage: StorageFacade
76
81
  router: HonoType
@@ -80,8 +85,8 @@ export type BunderstackApp<
80
85
  env: ValidatedEnv<TEnv>
81
86
  /** Email facade; always present — send() throws when email isn't configured. */
82
87
  email: EmailFacade
83
- /** Push the merged schema (user + internal tables) to the database. */
84
- provision: (options?: { force?: boolean }) => Promise<void>
88
+ /** Deploy-time introspection: what this app needs provisioned. */
89
+ manifest: BunderstackManifest
85
90
  /**
86
91
  * Type-only carrier for client inference (`createClient<typeof app>()`).
87
92
  * Never assigned at runtime.
@@ -110,7 +115,7 @@ export function createBunderstack<
110
115
  /** Builder callback receiving the pre-wired `t` instance. */
111
116
  trpc: (t: BunderstackTRPC<TSchema, ValidatedEnv<TEnv>>) => TRouter
112
117
  },
113
- ): BunderstackApp<TSchema, TAccess, BucketNamesOf<TStorage>, TEnv, TRouter>
118
+ ): Promise<BunderstackApp<TSchema, TAccess, BucketNamesOf<TStorage>, TEnv, TRouter>>
114
119
  export function createBunderstack<
115
120
  TSchema extends Record<string, unknown>,
116
121
  const TAccess extends Record<string, TableAccessInput> | undefined =
@@ -123,8 +128,8 @@ export function createBunderstack<
123
128
  /** Prebuilt tRPC router (escape hatch for multi-file setups). */
124
129
  trpc?: TRouter
125
130
  },
126
- ): BunderstackApp<TSchema, TAccess, BucketNamesOf<TStorage>, TEnv, TRouter>
127
- export function createBunderstack<
131
+ ): Promise<BunderstackApp<TSchema, TAccess, BucketNamesOf<TStorage>, TEnv, TRouter>>
132
+ export async function createBunderstack<
128
133
  TSchema extends Record<string, unknown>,
129
134
  const TAccess extends Record<string, TableAccessInput> | undefined =
130
135
  undefined,
@@ -136,35 +141,47 @@ export function createBunderstack<
136
141
  | AnyRouter
137
142
  | ((t: BunderstackTRPC<TSchema, ValidatedEnv<TEnv>>) => AnyRouter)
138
143
  },
139
- ): BunderstackApp<
140
- TSchema,
141
- TAccess,
142
- BucketNamesOf<TStorage>,
143
- TEnv,
144
- AnyRouter | undefined
144
+ ): Promise<
145
+ BunderstackApp<TSchema, TAccess, BucketNamesOf<TStorage>, TEnv, AnyRouter | undefined>
145
146
  > {
147
+ const dialect = detectDialect(options.schema)
146
148
  // Env is validated FIRST: the app refuses to boot on missing/invalid vars,
147
149
  // and everything downstream (config, email, trpc ctx) consumes the result.
148
150
  const env = validateEnv(options.env, {
149
151
  emailProvider: emailProviderTag(options.email),
152
+ defaultDatabaseUrl:
153
+ dialect === 'pg' ? 'file:./data.pglite' : 'file:./data.db',
150
154
  })
151
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
+ }
152
165
  const email = createEmail(options.email, { env })
153
166
  // Merge bunderstack's internal tables (file-meta, idempotency) into the
154
167
  // schema used for the db client + provisioning. CRUD/access stay on the USER
155
168
  // schema so internal tables never get a CRUD route.
156
169
  const mergedSchema = withInternalTables(options.schema)
157
- const db = createDb(mergedSchema, config.database)
170
+ const { db, driver } = await createDb(mergedSchema, {
171
+ ...config.database,
172
+ dialect,
173
+ })
158
174
  // `db` is typed with the merged schema (user tables + internal tables) so the
159
175
  // storage/idempotency code can query the internal tables. The public surface
160
- // and CRUD only expose the USER schema. TS can widen `LibSQLDatabase<merged>`
161
- // to `LibSQLDatabase<Record<string, unknown>>` on its own (storage/auth pass
162
- // `db` directly), but it can't *narrow* a generic schema view, so this single
163
- // intentional cast produces the user-facing db type. See `app.db` / crud below.
164
- const userDb = db as unknown as LibSQLDatabase<TSchema>
176
+ // and CRUD only expose the USER schema. TS can widen the merged-schema db type
177
+ // on its own (storage/auth pass `db` directly), but it can't *narrow* a
178
+ // generic schema view, so this single intentional cast produces the
179
+ // user-facing, per-dialect db type. See `app.db` / crud below.
180
+ const userDb = db as unknown as DbFor<TSchema>
165
181
  const auth = createAuth(
166
182
  db,
167
183
  withEmailAuthDefaults(config.auth, email, Boolean(options.email)),
184
+ dialect,
168
185
  )
169
186
  // Internal routers consume the narrow AuthSessionResolver contract, not the
170
187
  // raw better-auth instance. app.auth still exposes `auth` unchanged.
@@ -175,9 +192,10 @@ export function createBunderstack<
175
192
  )
176
193
  const realtimeBufferSize =
177
194
  typeof config.realtime === 'object' ? config.realtime.bufferSize : undefined
178
- const redisUrl = config.realtime
179
- ? resolveRealtimeRedisUrl(config.realtime, env)
180
- : undefined
195
+ const redisUrl =
196
+ config.realtime && !introspect
197
+ ? resolveRealtimeRedisUrl(config.realtime, env)
198
+ : undefined
181
199
  const broker = config.realtime
182
200
  ? redisUrl
183
201
  ? createRedisRealtimeBroker({
@@ -297,11 +315,25 @@ export function createBunderstack<
297
315
  env,
298
316
  email,
299
317
  trpcRouter,
300
- provision: (opts) =>
301
- provisionSchema(db, mergedSchema, {
302
- force: opts?.force,
303
- databaseUrl: config.database.url,
304
- }),
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
+ }),
325
+ }
326
+
327
+ // Hidden handle for the optional `bunderstack/provision` entry. Kept off the
328
+ // public type so provisioning stays opt-in (and drizzle-kit out of this
329
+ // module graph).
330
+ ;(app as WithProvisionInternals)[PROVISION_INTERNALS] = {
331
+ db,
332
+ schema: mergedSchema,
333
+ databaseUrl: config.database.url,
334
+ migrationsFolder: config.database.migrations,
335
+ dialect,
336
+ driver,
305
337
  }
306
338
 
307
339
  return app
@@ -314,9 +346,10 @@ export type {
314
346
  BunderstackConfig,
315
347
  ResolvedConfig,
316
348
  } from './config'
317
- export { provisionSchema } from './provision'
318
349
  export { validateEnv, createClientEnv, BunderstackEnvError } from './env'
319
350
  export type { EnvConfigInput, BaseEnv, ValidatedEnv } from './env'
351
+ export { buildManifest } from './manifest'
352
+ export type { BunderstackManifest, ManifestEnvVar } from './manifest'
320
353
  export { createEmail } from './email'
321
354
  export type {
322
355
  EmailMessage,
@@ -353,16 +386,3 @@ export type {
353
386
  } from './storage/buckets'
354
387
  // StorageFacade is declared+exported inline above.
355
388
  export type { TransformSpec } from './storage/thumbnails'
356
-
357
- // Re-export drizzle builders so consumers share bunderstack's drizzle-orm instance
358
- // and avoid type incompatibilities from duplicate installs.
359
- export {
360
- sqliteTable,
361
- integer,
362
- text,
363
- real,
364
- blob,
365
- numeric,
366
- foreignKey,
367
- } from 'drizzle-orm/sqlite-core'
368
- export { eq, and, or, not, gt, gte, lt, lte, desc, asc, sql } from 'drizzle-orm'
@@ -0,0 +1,45 @@
1
+ // src/internal-tables-pg.ts — Postgres twins of the internal tables. Same
2
+ // table/column names and row shapes as the sqlite originals; timestamps stay
3
+ // integer milliseconds (bigint mode:number) so shared code never branches.
4
+ import {
5
+ bigint,
6
+ index,
7
+ integer,
8
+ pgTable,
9
+ primaryKey,
10
+ text,
11
+ } from 'drizzle-orm/pg-core'
12
+
13
+ export const bunderstackFilesPg = pgTable(
14
+ 'bunderstack_file_meta',
15
+ {
16
+ fileId: text('file_id').primaryKey(),
17
+ bucket: text('bucket').notNull(),
18
+ ownerId: text('owner_id'),
19
+ scopeJson: text('scope_json'),
20
+ status: text('status').notNull(),
21
+ filename: text('filename'),
22
+ contentType: text('content_type'),
23
+ size: bigint('size', { mode: 'number' }),
24
+ createdAt: bigint('created_at', { mode: 'number' }).notNull(),
25
+ confirmedAt: bigint('confirmed_at', { mode: 'number' }),
26
+ },
27
+ (t) => [
28
+ index('bfm_owner').on(t.ownerId),
29
+ index('bfm_scope').on(t.bucket, t.scopeJson),
30
+ index('bfm_sweep').on(t.status, t.createdAt),
31
+ ],
32
+ )
33
+
34
+ export const bunderstackIdempotencyPg = pgTable(
35
+ '_bunderstack_idempotency',
36
+ {
37
+ key: text('key').notNull(),
38
+ tableName: text('table_name').notNull(),
39
+ bodyHash: text('body_hash').notNull(),
40
+ status: integer('status').notNull(),
41
+ response: text('response').notNull(),
42
+ expiresAt: bigint('expires_at', { mode: 'number' }).notNull(),
43
+ },
44
+ (t) => [primaryKey({ columns: [t.key, t.tableName] })],
45
+ )
@@ -1,4 +1,5 @@
1
- import { getTableName, isTable } from 'drizzle-orm'
1
+ import { getTableName, is, isTable } from 'drizzle-orm'
2
+ import { PgDatabase } from 'drizzle-orm/pg-core'
2
3
  import {
3
4
  index,
4
5
  integer,
@@ -7,6 +8,12 @@ import {
7
8
  text,
8
9
  } from 'drizzle-orm/sqlite-core'
9
10
 
11
+ import { detectDialect } from './dialect'
12
+ import {
13
+ bunderstackFilesPg,
14
+ bunderstackIdempotencyPg,
15
+ } from './internal-tables-pg'
16
+
10
17
  export const bunderstackFiles = sqliteTable(
11
18
  'bunderstack_file_meta',
12
19
  {
@@ -51,11 +58,30 @@ export const INTERNAL_TABLE_NAMES: ReadonlySet<string> = new Set([
51
58
  '_bunderstack_idempotency',
52
59
  ])
53
60
 
54
- const INTERNAL_TABLE_BY_NAME = new Map<string, (typeof INTERNAL_TABLES)[keyof typeof INTERNAL_TABLES]>([
55
- [getTableName(bunderstackFiles), bunderstackFiles],
56
- [getTableName(bunderstackIdempotency), bunderstackIdempotency],
61
+ export const INTERNAL_TABLES_PG = {
62
+ bunderstackFiles: bunderstackFilesPg,
63
+ bunderstackIdempotency: bunderstackIdempotencyPg,
64
+ } as const
65
+
66
+ // Both dialect twins count as "ours" for the re-export identity check.
67
+ const INTERNAL_TABLE_CANDIDATES = new Map<string, readonly unknown[]>([
68
+ [getTableName(bunderstackFiles), [bunderstackFiles, bunderstackFilesPg]],
69
+ [
70
+ getTableName(bunderstackIdempotency),
71
+ [bunderstackIdempotency, bunderstackIdempotencyPg],
72
+ ],
57
73
  ])
58
74
 
75
+ /** Internal file-meta table matching the db's dialect. */
76
+ export function filesTableFor(db: unknown) {
77
+ return is(db, PgDatabase) ? bunderstackFilesPg : bunderstackFiles
78
+ }
79
+
80
+ /** Internal idempotency table matching the db's dialect. */
81
+ export function idempotencyTableFor(db: unknown) {
82
+ return is(db, PgDatabase) ? bunderstackIdempotencyPg : bunderstackIdempotency
83
+ }
84
+
59
85
  export function withInternalTables<TSchema extends Record<string, unknown>>(
60
86
  schema: TSchema,
61
87
  ): TSchema & typeof INTERNAL_TABLES {
@@ -66,9 +92,9 @@ export function withInternalTables<TSchema extends Record<string, unknown>>(
66
92
  const name = getTableName(value)
67
93
  if (!INTERNAL_TABLE_NAMES.has(name)) continue
68
94
 
69
- const internal = INTERNAL_TABLE_BY_NAME.get(name)
70
- if (internal === value) {
71
- // Re-exported from bunderstack/schema — already in user schema.
95
+ const candidates = INTERNAL_TABLE_CANDIDATES.get(name)
96
+ if (candidates?.includes(value)) {
97
+ // Re-exported from bunderstack/schema(-pg) — already in user schema.
72
98
  continue
73
99
  }
74
100
 
@@ -77,7 +103,9 @@ export function withInternalTables<TSchema extends Record<string, unknown>>(
77
103
  )
78
104
  }
79
105
 
80
- for (const [key, table] of Object.entries(INTERNAL_TABLES)) {
106
+ const internal =
107
+ detectDialect(schema) === 'pg' ? INTERNAL_TABLES_PG : INTERNAL_TABLES
108
+ for (const [key, table] of Object.entries(internal)) {
81
109
  if (!(key in merged)) {
82
110
  ;(merged as Record<string, unknown>)[key] = table
83
111
  }
package/src/list-query.ts CHANGED
@@ -1,5 +1,3 @@
1
- import type { LibSQLDatabase } from 'drizzle-orm/libsql'
2
-
3
1
  import {
4
2
  and,
5
3
  asc,
@@ -7,14 +5,18 @@ import {
7
5
  eq,
8
6
  getTableColumns,
9
7
  gt,
8
+ ilike,
10
9
  inArray,
10
+ is,
11
11
  like,
12
12
  lt,
13
13
  or,
14
14
  sql,
15
15
  type SQL,
16
16
  } from 'drizzle-orm'
17
+ import { PgTable } from 'drizzle-orm/pg-core'
17
18
 
19
+ import type { AnyDb } from './dialect'
18
20
  import type { ResolvedTableAccess, SortOrder } from './access'
19
21
 
20
22
  import { ErrorCode, ListQueryError } from './errors'
@@ -180,9 +182,12 @@ function buildSearchWhere(
180
182
  if (!q || !searchableColumns?.length) return undefined
181
183
  const columns = getTableColumns(table)
182
184
  const pattern = `%${q.replace(/[%_\\]/g, (ch) => `\\${ch}`)}%`
185
+ // LIKE is case-insensitive in SQLite but case-sensitive in Postgres; use
186
+ // ilike there so search behaves identically across dialects.
187
+ const likeOp = is(table, PgTable) ? ilike : like
183
188
  const conditions = searchableColumns
184
189
  .filter((name) => name in columns)
185
- .map((name) => like(columns[name]!, pattern))
190
+ .map((name) => likeOp(columns[name]!, pattern))
186
191
  return conditions.length ? or(...conditions) : undefined
187
192
  }
188
193
 
@@ -325,7 +330,7 @@ function buildOrderBy(
325
330
  }
326
331
 
327
332
  export async function executeList<T extends Record<string, unknown>>(
328
- db: LibSQLDatabase<Record<string, unknown>>,
333
+ db: AnyDb,
329
334
  table: Parameters<typeof getTableColumns>[0],
330
335
  access: ResolvedTableAccess,
331
336
  params: ParsedListParams,
@@ -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
+ }
@@ -0,0 +1,28 @@
1
+ // src/provision-internals.ts
2
+ import type { AnyDb, Dialect } from './dialect'
3
+ import type { Driver } from './db'
4
+
5
+ /**
6
+ * Hidden handle connecting `createBunderstack()` to the optional
7
+ * `bunderstack/provision` entry. Lives in its own module so the main entry
8
+ * never imports provision code (and its drizzle-kit reference).
9
+ */
10
+ export const PROVISION_INTERNALS: unique symbol = Symbol.for(
11
+ 'bunderstack.provision-internals',
12
+ )
13
+
14
+ export interface ProvisionInternals {
15
+ /** Runtime db typed over the MERGED schema (user + internal tables). */
16
+ db: AnyDb
17
+ /** Merged schema used for push. */
18
+ schema: Record<string, unknown>
19
+ databaseUrl: string
20
+ /** Resolved migrations folder (config `database.migrations`). */
21
+ migrationsFolder: string
22
+ dialect: Dialect
23
+ driver: Driver
24
+ }
25
+
26
+ export interface WithProvisionInternals {
27
+ [PROVISION_INTERNALS]?: ProvisionInternals
28
+ }
package/src/provision.ts CHANGED
@@ -1,9 +1,25 @@
1
- import type { LibSQLDatabase } from 'drizzle-orm/libsql'
1
+ // src/provision.ts
2
+ import { access, mkdir } from 'node:fs/promises'
3
+ import { dirname, join } from 'node:path'
2
4
 
3
- import { mkdir } from 'node:fs/promises'
4
- import { dirname } from 'node:path'
5
+ import type { AnyDb, Dialect } from './dialect'
5
6
 
6
- async function ensureSqliteFileParent(url: string): Promise<void> {
7
+ import { detectDialect } from './dialect'
8
+ import {
9
+ PROVISION_INTERNALS,
10
+ type WithProvisionInternals,
11
+ } from './provision-internals'
12
+
13
+ /** Create the local backing directory for file-based urls, per dialect. */
14
+ async function ensureLocalDataDir(url: string, dialect: Dialect): Promise<void> {
15
+ if (dialect === 'pg') {
16
+ // PGlite data dir: `file:<dir>` or a bare path; server/memory urls need nothing.
17
+ if (/^postgres(ql)?:\/\//.test(url)) return
18
+ const raw = url.startsWith('file:') ? url.slice('file:'.length) : url
19
+ if (raw === ':memory:' || raw.startsWith('memory://')) return
20
+ await mkdir(raw, { recursive: true })
21
+ return
22
+ }
7
23
  const match = /^file:(.+)$/.exec(url)
8
24
  if (!match) return
9
25
  const filePath = match[1]!
@@ -11,22 +27,50 @@ async function ensureSqliteFileParent(url: string): Promise<void> {
11
27
  await mkdir(dirname(filePath), { recursive: true })
12
28
  }
13
29
 
30
+ async function exists(path: string): Promise<boolean> {
31
+ try {
32
+ await access(path)
33
+ return true
34
+ } catch {
35
+ return false
36
+ }
37
+ }
38
+
39
+ const DRIZZLE_KIT_HINT =
40
+ '[bunderstack] Schema push requires drizzle-kit, which is not installed.\n' +
41
+ ' Development: run `bun add -d drizzle-kit` — provision() will push schema changes to the database on startup.\n' +
42
+ ' Production: generate migrations locally with `bunx drizzle-kit generate` and commit the folder — provision() applies them without drizzle-kit.'
43
+
14
44
  /** Push the merged schema to the database via drizzle-kit/api. */
15
45
  export async function provisionSchema<TSchema extends Record<string, unknown>>(
16
- db: LibSQLDatabase<TSchema>,
46
+ db: AnyDb,
17
47
  schema: TSchema,
18
48
  options?: { force?: boolean; databaseUrl?: string },
19
49
  ): Promise<void> {
50
+ const dialect = detectDialect(schema)
20
51
  if (options?.databaseUrl) {
21
- await ensureSqliteFileParent(options.databaseUrl)
52
+ await ensureLocalDataDir(options.databaseUrl, dialect)
22
53
  }
23
54
 
24
- const { pushSQLiteSchema } = await import('drizzle-kit/api')
25
- const result = await pushSQLiteSchema(schema, db)
55
+ let kit: typeof import('drizzle-kit/api')
56
+ try {
57
+ // Ignore comments keep bundlers (vite/nitro, webpack) from resolving
58
+ // drizzle-kit at build time — this branch only runs in development.
59
+ kit = await import(
60
+ /* @vite-ignore */ /* webpackIgnore: true */ 'drizzle-kit/api'
61
+ )
62
+ } catch (cause) {
63
+ throw new Error(DRIZZLE_KIT_HINT, { cause })
64
+ }
65
+
66
+ const result =
67
+ dialect === 'pg'
68
+ ? await kit.pushSchema(schema, db as never)
69
+ : await kit.pushSQLiteSchema(schema, db as never)
26
70
 
27
71
  if (result.hasDataLoss && !options?.force) {
28
72
  throw new Error(
29
- '[bunderstack] Schema push would cause data loss. Run `bunx drizzle-kit push` or call app.provision({ force: true }).',
73
+ '[bunderstack] Schema push would cause data loss. Run `bunx drizzle-kit push` or call provision(app, { force: true }).',
30
74
  )
31
75
  }
32
76
 
@@ -44,3 +88,50 @@ export async function provisionSchema<TSchema extends Record<string, unknown>>(
44
88
  `[bunderstack] provisioned ${result.statementsToExecute.length} schema change(s)`,
45
89
  )
46
90
  }
91
+
92
+ const MIGRATOR_MODULES = {
93
+ libsql: 'drizzle-orm/libsql/migrator',
94
+ pglite: 'drizzle-orm/pglite/migrator',
95
+ 'bun-sql': 'drizzle-orm/bun-sql/migrator',
96
+ 'postgres-js': 'drizzle-orm/postgres-js/migrator',
97
+ } as const
98
+
99
+ /**
100
+ * Provision the database for a Bunderstack app.
101
+ *
102
+ * The migrations folder is the mode switch:
103
+ * - `<migrations>/meta/_journal.json` exists → apply committed migrations via
104
+ * drizzle-orm's migrator. Pure runtime; drizzle-kit is never imported, so a
105
+ * fresh clone deploys with `bun install --production`.
106
+ * - No migrations → development: push the schema straight to the database via
107
+ * drizzle-kit (install it with `bun add -d drizzle-kit`).
108
+ *
109
+ * Once you run `bunx drizzle-kit generate` for the first time, provision stops
110
+ * pushing and every schema change goes through an explicit `generate`.
111
+ */
112
+ export async function provision(
113
+ app: object,
114
+ options?: { force?: boolean },
115
+ ): Promise<void> {
116
+ const internals = (app as WithProvisionInternals)[PROVISION_INTERNALS]
117
+ if (!internals) {
118
+ throw new Error(
119
+ '[bunderstack] provision() expects the app returned by createBunderstack().',
120
+ )
121
+ }
122
+
123
+ const { db, schema, databaseUrl, migrationsFolder, dialect, driver } =
124
+ internals
125
+ const journal = join(migrationsFolder, 'meta', '_journal.json')
126
+
127
+ if (await exists(journal)) {
128
+ await ensureLocalDataDir(databaseUrl, dialect)
129
+ const { migrate } = (await import(
130
+ /* @vite-ignore */ /* webpackIgnore: true */ MIGRATOR_MODULES[driver]
131
+ )) as { migrate: (db: never, cfg: { migrationsFolder: string }) => Promise<void> }
132
+ await migrate(db as never, { migrationsFolder })
133
+ return
134
+ }
135
+
136
+ await provisionSchema(db, schema, { force: options?.force, databaseUrl })
137
+ }
@@ -0,0 +1,6 @@
1
+ // src/schema-export-pg.ts — pg twins under the same names bunderstack/schema
2
+ // uses, so `export * from 'bunderstack/schema/pg'` mirrors the sqlite setup.
3
+ export {
4
+ bunderstackFilesPg as bunderstackFiles,
5
+ bunderstackIdempotencyPg as bunderstackIdempotency,
6
+ } from './internal-tables-pg'
@@ -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) : []
@@ -1,6 +1,5 @@
1
1
  // src/storage/delete.ts
2
- import type { LibSQLDatabase } from 'drizzle-orm/libsql'
3
-
2
+ import type { AnyDb } from '../dialect'
4
3
  import type { StorageAdapter } from './index'
5
4
 
6
5
  import { deleteFileMetaRow } from './file-meta'
@@ -14,7 +13,7 @@ import { deleteFileMetaRow } from './file-meta'
14
13
  */
15
14
  export async function deleteFileWithDerivatives(
16
15
  adapter: StorageAdapter,
17
- db: LibSQLDatabase<Record<string, unknown>>,
16
+ db: AnyDb,
18
17
  fileId: string,
19
18
  ): Promise<void> {
20
19
  if (adapter.list) {