bunderstack 0.1.0 → 0.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bunderstack",
3
- "version": "0.1.0",
3
+ "version": "0.2.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?:
@@ -125,6 +129,7 @@ export function resolveConfig<TSchema extends Record<string, unknown>>(
125
129
  database: {
126
130
  url: parsed.database?.url ?? resolvedEnv.DATABASE_URL,
127
131
  authToken: parsed.database?.authToken ?? resolvedEnv.DATABASE_AUTH_TOKEN,
132
+ migrations: parsed.database?.migrations ?? './migrations',
128
133
  },
129
134
  auth: (() => {
130
135
  const authInput = options.auth ?? {}
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,
@@ -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
  }
package/src/index.ts CHANGED
@@ -1,18 +1,19 @@
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'
18
19
  import { createTRPC, type BunderstackTRPC } from './trpc'
@@ -20,7 +21,10 @@ import { buildCrudRouter } from './crud'
20
21
  import { createDb } from './db'
21
22
  import { buildHandler } from './handler'
22
23
  import { withInternalTables } from './internal-tables'
23
- import { provisionSchema } from './provision'
24
+ import {
25
+ PROVISION_INTERNALS,
26
+ type WithProvisionInternals,
27
+ } from './provision-internals'
24
28
  import { createRealtimeBroker, buildRealtimeRouter } from './realtime/index'
25
29
  import { createRedisRealtimeBroker } from './realtime/redis'
26
30
  import { deleteFileWithDerivatives } from './storage/delete'
@@ -70,7 +74,7 @@ export type BunderstackApp<
70
74
  TRouter = undefined,
71
75
  > = {
72
76
  handler: (req: Request) => Promise<Response>
73
- db: LibSQLDatabase<TSchema>
77
+ db: DbFor<TSchema>
74
78
  auth: AuthInstance
75
79
  storage: StorageFacade
76
80
  router: HonoType
@@ -80,8 +84,6 @@ export type BunderstackApp<
80
84
  env: ValidatedEnv<TEnv>
81
85
  /** Email facade; always present — send() throws when email isn't configured. */
82
86
  email: EmailFacade
83
- /** Push the merged schema (user + internal tables) to the database. */
84
- provision: (options?: { force?: boolean }) => Promise<void>
85
87
  /**
86
88
  * Type-only carrier for client inference (`createClient<typeof app>()`).
87
89
  * Never assigned at runtime.
@@ -110,7 +112,7 @@ export function createBunderstack<
110
112
  /** Builder callback receiving the pre-wired `t` instance. */
111
113
  trpc: (t: BunderstackTRPC<TSchema, ValidatedEnv<TEnv>>) => TRouter
112
114
  },
113
- ): BunderstackApp<TSchema, TAccess, BucketNamesOf<TStorage>, TEnv, TRouter>
115
+ ): Promise<BunderstackApp<TSchema, TAccess, BucketNamesOf<TStorage>, TEnv, TRouter>>
114
116
  export function createBunderstack<
115
117
  TSchema extends Record<string, unknown>,
116
118
  const TAccess extends Record<string, TableAccessInput> | undefined =
@@ -123,8 +125,8 @@ export function createBunderstack<
123
125
  /** Prebuilt tRPC router (escape hatch for multi-file setups). */
124
126
  trpc?: TRouter
125
127
  },
126
- ): BunderstackApp<TSchema, TAccess, BucketNamesOf<TStorage>, TEnv, TRouter>
127
- export function createBunderstack<
128
+ ): Promise<BunderstackApp<TSchema, TAccess, BucketNamesOf<TStorage>, TEnv, TRouter>>
129
+ export async function createBunderstack<
128
130
  TSchema extends Record<string, unknown>,
129
131
  const TAccess extends Record<string, TableAccessInput> | undefined =
130
132
  undefined,
@@ -136,17 +138,16 @@ export function createBunderstack<
136
138
  | AnyRouter
137
139
  | ((t: BunderstackTRPC<TSchema, ValidatedEnv<TEnv>>) => AnyRouter)
138
140
  },
139
- ): BunderstackApp<
140
- TSchema,
141
- TAccess,
142
- BucketNamesOf<TStorage>,
143
- TEnv,
144
- AnyRouter | undefined
141
+ ): Promise<
142
+ BunderstackApp<TSchema, TAccess, BucketNamesOf<TStorage>, TEnv, AnyRouter | undefined>
145
143
  > {
144
+ const dialect = detectDialect(options.schema)
146
145
  // Env is validated FIRST: the app refuses to boot on missing/invalid vars,
147
146
  // and everything downstream (config, email, trpc ctx) consumes the result.
148
147
  const env = validateEnv(options.env, {
149
148
  emailProvider: emailProviderTag(options.email),
149
+ defaultDatabaseUrl:
150
+ dialect === 'pg' ? 'file:./data.pglite' : 'file:./data.db',
150
151
  })
151
152
  const config = resolveConfig(options, env)
152
153
  const email = createEmail(options.email, { env })
@@ -154,17 +155,21 @@ export function createBunderstack<
154
155
  // schema used for the db client + provisioning. CRUD/access stay on the USER
155
156
  // schema so internal tables never get a CRUD route.
156
157
  const mergedSchema = withInternalTables(options.schema)
157
- const db = createDb(mergedSchema, config.database)
158
+ const { db, driver } = await createDb(mergedSchema, {
159
+ ...config.database,
160
+ dialect,
161
+ })
158
162
  // `db` is typed with the merged schema (user tables + internal tables) so the
159
163
  // 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>
164
+ // and CRUD only expose the USER schema. TS can widen the merged-schema db type
165
+ // on its own (storage/auth pass `db` directly), but it can't *narrow* a
166
+ // generic schema view, so this single intentional cast produces the
167
+ // user-facing, per-dialect db type. See `app.db` / crud below.
168
+ const userDb = db as unknown as DbFor<TSchema>
165
169
  const auth = createAuth(
166
170
  db,
167
171
  withEmailAuthDefaults(config.auth, email, Boolean(options.email)),
172
+ dialect,
168
173
  )
169
174
  // Internal routers consume the narrow AuthSessionResolver contract, not the
170
175
  // raw better-auth instance. app.auth still exposes `auth` unchanged.
@@ -297,11 +302,18 @@ export function createBunderstack<
297
302
  env,
298
303
  email,
299
304
  trpcRouter,
300
- provision: (opts) =>
301
- provisionSchema(db, mergedSchema, {
302
- force: opts?.force,
303
- databaseUrl: config.database.url,
304
- }),
305
+ }
306
+
307
+ // Hidden handle for the optional `bunderstack/provision` entry. Kept off the
308
+ // public type so provisioning stays opt-in (and drizzle-kit out of this
309
+ // module graph).
310
+ ;(app as WithProvisionInternals)[PROVISION_INTERNALS] = {
311
+ db,
312
+ schema: mergedSchema,
313
+ databaseUrl: config.database.url,
314
+ migrationsFolder: config.database.migrations,
315
+ dialect,
316
+ driver,
305
317
  }
306
318
 
307
319
  return app
@@ -314,7 +326,6 @@ export type {
314
326
  BunderstackConfig,
315
327
  ResolvedConfig,
316
328
  } from './config'
317
- export { provisionSchema } from './provision'
318
329
  export { validateEnv, createClientEnv, BunderstackEnvError } from './env'
319
330
  export type { EnvConfigInput, BaseEnv, ValidatedEnv } from './env'
320
331
  export { createEmail } from './email'
@@ -353,16 +364,3 @@ export type {
353
364
  } from './storage/buckets'
354
365
  // StorageFacade is declared+exported inline above.
355
366
  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,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'
@@ -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) {
@@ -27,21 +27,20 @@
27
27
  * See docs/plans/2026-06-28-multi-bucket-storage-design.md §6 for full rationale.
28
28
  */
29
29
 
30
- import type { LibSQLDatabase } from 'drizzle-orm/libsql'
31
-
32
30
  import { eq, and, lt, sql } from 'drizzle-orm'
33
31
 
32
+ import type { AnyDb } from '../dialect'
34
33
  import type { ScopeMap } from '../access'
35
34
 
36
35
  import { rowMatchesScope } from '../access'
37
- import { bunderstackFiles } from '../internal-tables'
36
+ import { bunderstackFiles, filesTableFor } from '../internal-tables'
38
37
 
39
38
  export type FileMetaRow = typeof bunderstackFiles.$inferSelect
40
39
 
41
40
  // ─── CRUD ─────────────────────────────────────────────────────────────────────
42
41
 
43
42
  export async function insertPendingFile(
44
- db: LibSQLDatabase<Record<string, unknown>>,
43
+ db: AnyDb,
45
44
  input: {
46
45
  fileId: string
47
46
  bucket: string
@@ -51,7 +50,8 @@ export async function insertPendingFile(
51
50
  contentType: string | null
52
51
  },
53
52
  ): Promise<void> {
54
- await db.insert(bunderstackFiles).values({
53
+ const files = filesTableFor(db)
54
+ await db.insert(files).values({
55
55
  fileId: input.fileId,
56
56
  bucket: input.bucket,
57
57
  ownerId: input.ownerId,
@@ -66,7 +66,7 @@ export async function insertPendingFile(
66
66
  }
67
67
 
68
68
  export async function insertReadyFile(
69
- db: LibSQLDatabase<Record<string, unknown>>,
69
+ db: AnyDb,
70
70
  input: {
71
71
  fileId: string
72
72
  bucket: string
@@ -77,8 +77,9 @@ export async function insertReadyFile(
77
77
  size: number | null
78
78
  },
79
79
  ): Promise<void> {
80
+ const files = filesTableFor(db)
80
81
  const now = Date.now()
81
- await db.insert(bunderstackFiles).values({
82
+ await db.insert(files).values({
82
83
  fileId: input.fileId,
83
84
  bucket: input.bucket,
84
85
  ownerId: input.ownerId,
@@ -93,79 +94,76 @@ export async function insertReadyFile(
93
94
  }
94
95
 
95
96
  export async function markFileReady(
96
- db: LibSQLDatabase<Record<string, unknown>>,
97
+ db: AnyDb,
97
98
  fileId: string,
98
99
  patch: { size: number | null; contentType: string | null },
99
100
  ): Promise<void> {
101
+ const files = filesTableFor(db)
100
102
  await db
101
- .update(bunderstackFiles)
103
+ .update(files)
102
104
  .set({
103
105
  status: 'ready',
104
106
  confirmedAt: Date.now(),
105
107
  size: patch.size,
106
108
  contentType: patch.contentType,
107
109
  })
108
- .where(eq(bunderstackFiles.fileId, fileId))
110
+ .where(eq(files.fileId, fileId))
109
111
  }
110
112
 
111
113
  export async function getFileMeta(
112
- db: LibSQLDatabase<Record<string, unknown>>,
114
+ db: AnyDb,
113
115
  fileId: string,
114
116
  ): Promise<FileMetaRow | null> {
117
+ const files = filesTableFor(db)
115
118
  const rows = await db
116
119
  .select()
117
- .from(bunderstackFiles)
118
- .where(eq(bunderstackFiles.fileId, fileId))
120
+ .from(files)
121
+ .where(eq(files.fileId, fileId))
119
122
  .limit(1)
120
- return rows[0] ?? null
123
+ return (rows[0] as FileMetaRow | undefined) ?? null
121
124
  }
122
125
 
123
126
  export async function deleteFileMetaRow(
124
- db: LibSQLDatabase<Record<string, unknown>>,
127
+ db: AnyDb,
125
128
  fileId: string,
126
129
  ): Promise<void> {
127
- await db.delete(bunderstackFiles).where(eq(bunderstackFiles.fileId, fileId))
130
+ const files = filesTableFor(db)
131
+ await db.delete(files).where(eq(files.fileId, fileId))
128
132
  }
129
133
 
130
134
  // ─── Sweep ────────────────────────────────────────────────────────────────────
131
135
 
132
136
  export async function listStalePendingFiles(
133
- db: LibSQLDatabase<Record<string, unknown>>,
137
+ db: AnyDb,
134
138
  olderThanMs: number,
135
139
  ): Promise<FileMetaRow[]> {
140
+ const files = filesTableFor(db)
136
141
  return db
137
142
  .select()
138
- .from(bunderstackFiles)
139
- .where(
140
- and(
141
- eq(bunderstackFiles.status, 'pending'),
142
- lt(bunderstackFiles.createdAt, olderThanMs),
143
- ),
144
- )
143
+ .from(files)
144
+ .where(and(eq(files.status, 'pending'), lt(files.createdAt, olderThanMs)))
145
145
  }
146
146
 
147
147
  // ─── Quota ────────────────────────────────────────────────────────────────────
148
148
 
149
149
  export async function sumReadySize(
150
- db: LibSQLDatabase<Record<string, unknown>>,
150
+ db: AnyDb,
151
151
  q: { bucket: string; ownerId?: string; scopeJson?: string },
152
152
  ): Promise<number> {
153
- const conditions = [
154
- eq(bunderstackFiles.status, 'ready'),
155
- eq(bunderstackFiles.bucket, q.bucket),
156
- ]
153
+ const files = filesTableFor(db)
154
+ const conditions = [eq(files.status, 'ready'), eq(files.bucket, q.bucket)]
157
155
  if (q.ownerId !== undefined) {
158
- conditions.push(eq(bunderstackFiles.ownerId, q.ownerId))
156
+ conditions.push(eq(files.ownerId, q.ownerId))
159
157
  }
160
158
  if (q.scopeJson !== undefined) {
161
- conditions.push(eq(bunderstackFiles.scopeJson, q.scopeJson))
159
+ conditions.push(eq(files.scopeJson, q.scopeJson))
162
160
  }
163
161
 
164
162
  const rows = await db
165
163
  .select({
166
- total: sql<number>`coalesce(sum(${bunderstackFiles.size}), 0)`,
164
+ total: sql<number>`coalesce(sum(${files.size}), 0)`,
167
165
  })
168
- .from(bunderstackFiles)
166
+ .from(files)
169
167
  .where(and(...conditions))
170
168
 
171
169
  const raw = rows[0]?.total ?? 0
@@ -1,4 +1,3 @@
1
- import type { LibSQLDatabase } from 'drizzle-orm/libsql'
2
1
  // src/storage/router.ts
3
2
  import type { Context } from 'hono'
4
3
 
@@ -11,6 +10,7 @@ import type {
11
10
  AuthSessionResolver,
12
11
  OperationRule,
13
12
  } from '../access'
13
+ import type { AnyDb } from '../dialect'
14
14
  import type { BucketStorageRegistry } from './registry'
15
15
 
16
16
  import { checkAccess, resolveSession } from '../access'
@@ -35,7 +35,7 @@ import {
35
35
 
36
36
  export interface BucketStorageRouterOptions {
37
37
  registry: BucketStorageRegistry
38
- db: LibSQLDatabase<Record<string, unknown>>
38
+ db: AnyDb
39
39
  auth: AuthSessionResolver | undefined
40
40
  /** Default presign TTL (seconds) for PUT/GET URLs. */
41
41
  presignExpiresSec?: number
@@ -511,7 +511,7 @@ export function buildBucketStorageRouter(
511
511
  * dimension (perUser uses ownerId; perScope uses scopeJson).
512
512
  */
513
513
  async function quotaExceeded(
514
- db: LibSQLDatabase<Record<string, unknown>>,
514
+ db: AnyDb,
515
515
  bucket: string,
516
516
  quota: { perUserBytes?: number; perScopeBytes?: number },
517
517
  ownerId: string | undefined,
@@ -1,6 +1,5 @@
1
1
  // src/storage/sweep.ts
2
- import type { LibSQLDatabase } from 'drizzle-orm/libsql'
3
-
2
+ import type { AnyDb } from '../dialect'
4
3
  import type { BucketStorageRegistry } from './registry'
5
4
 
6
5
  import { deleteFileMetaRow, listStalePendingFiles } from './file-meta'
@@ -12,7 +11,7 @@ import { deleteFileMetaRow, listStalePendingFiles } from './file-meta'
12
11
  */
13
12
  export async function sweepOrphans(
14
13
  registry: BucketStorageRegistry,
15
- db: LibSQLDatabase<Record<string, unknown>>,
14
+ db: AnyDb,
16
15
  olderThanMs: number,
17
16
  ): Promise<number> {
18
17
  const cutoff = Date.now() - olderThanMs
package/src/trpc.ts CHANGED
@@ -1,17 +1,16 @@
1
1
  // src/trpc.ts — pre-wired tRPC instance for bunderstack endpoints.
2
- import type { LibSQLDatabase } from 'drizzle-orm/libsql'
3
-
4
2
  import { initTRPC, TRPCError } from '@trpc/server'
5
3
  import superjson from 'superjson'
6
4
 
7
5
  import type { AccessUser } from './access'
6
+ import type { DbFor } from './db'
8
7
  import type { EmailFacade } from './email'
9
8
 
10
9
  export type TRPCContext<
11
10
  TSchema extends Record<string, unknown>,
12
11
  TEnvResult = Record<string, unknown>,
13
12
  > = {
14
- db: LibSQLDatabase<TSchema>
13
+ db: DbFor<TSchema>
15
14
  user: AccessUser | null
16
15
  env: TEnvResult
17
16
  email: EmailFacade
@@ -0,0 +1,24 @@
1
+ // src/typeid-pg.ts — Postgres twin of the typeid column builder. The codec
2
+ // (generate/parse/encode/decode) is dialect-neutral and lives in ./typeid;
3
+ // only the drizzle customType wrapper differs.
4
+ import { customType } from 'drizzle-orm/pg-core'
5
+
6
+ import { isValidPrefix, type TypeId } from './typeid'
7
+
8
+ /**
9
+ * Drizzle column builder for a branded TypeID text value (Postgres). Stores a
10
+ * plain `text` column so drizzle-kit migrations and `$inferSelect` work
11
+ * unchanged.
12
+ *
13
+ * id: typeid('post').primaryKey().$defaultFn(() => generate('post'))
14
+ */
15
+ export function typeid<P extends string>(prefix: P) {
16
+ if (!isValidPrefix(prefix))
17
+ throw new Error(`Invalid typeid prefix: "${prefix}"`)
18
+ return customType<{ data: TypeId<P>; driverData: string }>({
19
+ dataType: () => 'text',
20
+ })()
21
+ }
22
+
23
+ export { generate, parse, asTypeId, encode, decode } from './typeid'
24
+ export type { TypeId } from './typeid'