bunderstack 0.6.1 → 0.8.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 +49 -12
- package/package.json +23 -10
- package/src/config.ts +36 -6
- package/src/crud.ts +10 -12
- package/src/database/adapter.ts +26 -0
- package/src/database/bun-sql.ts +23 -0
- package/src/database/libsql.ts +28 -0
- package/src/database/pglite.ts +33 -0
- package/src/database/postgres-js.ts +33 -0
- package/src/db.ts +32 -74
- package/src/email/smtp.ts +45 -0
- package/src/email.ts +13 -59
- package/src/index.ts +366 -295
- package/src/jobs/define.ts +3 -0
- package/src/lifecycle.ts +7 -2
- package/src/provision-internals.ts +2 -1
- package/src/provision.ts +7 -18
- package/src/realtime/facade.ts +36 -0
- package/src/trpc.ts +2 -0
- package/src/typeid.ts +2 -2
package/README.md
CHANGED
|
@@ -6,15 +6,20 @@ and get CRUD APIs, auth, file storage, realtime, typed custom endpoints
|
|
|
6
6
|
single `Request → Response` handler.
|
|
7
7
|
|
|
8
8
|
```sh
|
|
9
|
-
bun add bunderstack
|
|
9
|
+
bun add bunderstack better-auth drizzle-orm hono zod @libsql/client
|
|
10
10
|
```
|
|
11
11
|
|
|
12
12
|
```ts
|
|
13
13
|
import { createBunderstack } from 'bunderstack'
|
|
14
|
+
import { libsql } from 'bunderstack/database/libsql'
|
|
14
15
|
import * as schema from './schema'
|
|
15
16
|
|
|
16
17
|
const app = await createBunderstack({
|
|
17
18
|
schema,
|
|
19
|
+
database: {
|
|
20
|
+
adapter: libsql(),
|
|
21
|
+
url: 'file:./data.db',
|
|
22
|
+
},
|
|
18
23
|
auth: { emailAndPassword: { enabled: true } },
|
|
19
24
|
access: {
|
|
20
25
|
posts: { ownerColumn: 'userId', list: 'public', create: 'authenticated' },
|
|
@@ -34,15 +39,15 @@ through env vars alone — no code changes required.
|
|
|
34
39
|
|
|
35
40
|
### Overrides (beat code-level config)
|
|
36
41
|
|
|
37
|
-
| Var
|
|
38
|
-
|
|
|
39
|
-
| `BUNDERSTACK_DATABASE_URL`
|
|
40
|
-
| `BUNDERSTACK_DATABASE_AUTH_TOKEN`
|
|
41
|
-
| `BUNDERSTACK_S3_ENDPOINT`
|
|
42
|
-
| `BUNDERSTACK_S3_BUCKET`
|
|
43
|
-
| `BUNDERSTACK_S3_ACCESS_KEY_ID` / `BUNDERSTACK_S3_SECRET_ACCESS_KEY` | Credentials
|
|
44
|
-
| `BUNDERSTACK_S3_REGION`
|
|
45
|
-
| `BUNDERSTACK_S3_PUBLIC_URL`
|
|
42
|
+
| Var | Effect |
|
|
43
|
+
| ------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- |
|
|
44
|
+
| `BUNDERSTACK_DATABASE_URL` | Database URL; wins over `database.url` in code |
|
|
45
|
+
| `BUNDERSTACK_DATABASE_AUTH_TOKEN` | Auth token for the database |
|
|
46
|
+
| `BUNDERSTACK_S3_ENDPOINT` | Forces ALL buckets onto this S3 backend (code-level `local`/per-bucket `s3` blocks are ignored) |
|
|
47
|
+
| `BUNDERSTACK_S3_BUCKET` | Physical bucket name (logical buckets become key prefixes) |
|
|
48
|
+
| `BUNDERSTACK_S3_ACCESS_KEY_ID` / `BUNDERSTACK_S3_SECRET_ACCESS_KEY` | Credentials |
|
|
49
|
+
| `BUNDERSTACK_S3_REGION` | Region (default `auto`) |
|
|
50
|
+
| `BUNDERSTACK_S3_PUBLIC_URL` | Public base URL for `visibility: 'public'` buckets |
|
|
46
51
|
|
|
47
52
|
Plain `DATABASE_URL` / `S3_*` vars keep their usual role: fallbacks that
|
|
48
53
|
code-level config wins over.
|
|
@@ -50,8 +55,9 @@ code-level config wins over.
|
|
|
50
55
|
### Introspection
|
|
51
56
|
|
|
52
57
|
Set `BUNDERSTACK_INTROSPECT=1` and import the app declaration: the boot is
|
|
53
|
-
guaranteed offline (
|
|
54
|
-
don't throw. Then read
|
|
58
|
+
guaranteed offline (the selected adapter returns its `drizzle.mock({ schema })`
|
|
59
|
+
database, no Redis) and missing user env vars don't throw. Then read
|
|
60
|
+
`app.manifest`:
|
|
55
61
|
|
|
56
62
|
```ts
|
|
57
63
|
process.env.BUNDERSTACK_INTROSPECT = '1'
|
|
@@ -60,6 +66,11 @@ console.log(JSON.stringify(app.manifest))
|
|
|
60
66
|
// { version: 2, dialect, tables, tableMap, systemTables, background, ... }
|
|
61
67
|
```
|
|
62
68
|
|
|
69
|
+
Real database clients belong to the app. Call `await app.close()` when a
|
|
70
|
+
standalone process or test is finished; it closes the real libSQL, PGlite,
|
|
71
|
+
postgres.js, or Bun SQL client selected by `database.adapter`. Introspection
|
|
72
|
+
mocks own no client, so there is nothing to close for that database path.
|
|
73
|
+
|
|
63
74
|
### Background runtime
|
|
64
75
|
|
|
65
76
|
Declaring jobs does not start a worker. Queue jobs (`j.job()`) are processed by
|
|
@@ -79,6 +90,32 @@ for local standalone development. `app.manifest.background` tells Bunderhost
|
|
|
79
90
|
whether to deploy an always-on worker (queue jobs) or only HTTP-delivered cron
|
|
80
91
|
(cron-only).
|
|
81
92
|
|
|
93
|
+
### Publishing custom writes to realtime
|
|
94
|
+
|
|
95
|
+
Generated CRUD publishes automatically. Writes made directly through `app.db`
|
|
96
|
+
or `ctx.db` are explicit: publish the complete row returned by Drizzle after the
|
|
97
|
+
write commits.
|
|
98
|
+
|
|
99
|
+
```ts
|
|
100
|
+
const [avatar] = await ctx.db
|
|
101
|
+
.update(schema.avatars)
|
|
102
|
+
.set({ status: 'completed' })
|
|
103
|
+
.where(eq(schema.avatars.id, avatarId))
|
|
104
|
+
.returning()
|
|
105
|
+
|
|
106
|
+
await ctx.realtime.publish(schema.avatars, 'update', avatar)
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
The same typed facade is available as `app.realtime`, in tRPC context, and in
|
|
110
|
+
queue-job and cron context. Passing the Drizzle table makes a table-name typo a
|
|
111
|
+
type error and constrains the record to that table's select model.
|
|
112
|
+
|
|
113
|
+
Publish after an enclosing transaction resolves, not from inside it. The full
|
|
114
|
+
row is required because realtime access filtering may inspect its `id`, owner,
|
|
115
|
+
or read-scope columns. Subscriber access checks, Redis fan-out, and replay are
|
|
116
|
+
applied automatically by the existing broker. When server realtime is not
|
|
117
|
+
configured, `realtime.enabled` is `false` and `publish()` is a no-op.
|
|
118
|
+
|
|
82
119
|
## Shipping TypeScript source
|
|
83
120
|
|
|
84
121
|
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.
|
|
3
|
+
"version": "0.8.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,6 +33,10 @@
|
|
|
33
33
|
"exports": {
|
|
34
34
|
".": "./src/index.ts",
|
|
35
35
|
"./access": "./src/access.ts",
|
|
36
|
+
"./database/bun-sql": "./src/database/bun-sql.ts",
|
|
37
|
+
"./database/libsql": "./src/database/libsql.ts",
|
|
38
|
+
"./database/pglite": "./src/database/pglite.ts",
|
|
39
|
+
"./database/postgres-js": "./src/database/postgres-js.ts",
|
|
36
40
|
"./provision": "./src/provision.ts",
|
|
37
41
|
"./schema": "./src/schema-export.ts",
|
|
38
42
|
"./schema/pg": "./src/schema-export-pg.ts",
|
|
@@ -40,7 +44,8 @@
|
|
|
40
44
|
"./typeid/pg": "./src/typeid-pg.ts",
|
|
41
45
|
"./env": "./src/env.ts",
|
|
42
46
|
"./trpc": "./src/trpc.ts",
|
|
43
|
-
"./cron": "./src/cron.ts"
|
|
47
|
+
"./cron": "./src/cron.ts",
|
|
48
|
+
"./email/smtp": "./src/email/smtp.ts"
|
|
44
49
|
},
|
|
45
50
|
"scripts": {
|
|
46
51
|
"test": "bun test",
|
|
@@ -49,26 +54,31 @@
|
|
|
49
54
|
"db:migrate": "drizzle-kit migrate"
|
|
50
55
|
},
|
|
51
56
|
"dependencies": {
|
|
52
|
-
"
|
|
53
|
-
"better-auth": "^1.0.0",
|
|
54
|
-
"hono": "^4.0.0",
|
|
55
|
-
"superjson": "^2.2.0",
|
|
56
|
-
"zod": "^4.4.3"
|
|
57
|
+
"superjson": "^2.2.0"
|
|
57
58
|
},
|
|
58
59
|
"devDependencies": {
|
|
59
60
|
"@electric-sql/pglite": ">=0.3.0",
|
|
60
61
|
"@libsql/client": ">=0.14.0",
|
|
62
|
+
"@trpc/server": "^11.0.0",
|
|
63
|
+
"@types/nodemailer": "^6",
|
|
64
|
+
"better-auth": "^1.0.0",
|
|
61
65
|
"drizzle-kit": "^0.30.0",
|
|
62
|
-
"drizzle-orm": "^0.45.0"
|
|
66
|
+
"drizzle-orm": "^0.45.0",
|
|
67
|
+
"hono": "^4.0.0",
|
|
68
|
+
"zod": "^4.4.3"
|
|
63
69
|
},
|
|
64
70
|
"peerDependencies": {
|
|
65
71
|
"@electric-sql/pglite": ">=0.3.0",
|
|
66
72
|
"@libsql/client": ">=0.14.0",
|
|
73
|
+
"@trpc/server": "^11.0.0",
|
|
74
|
+
"better-auth": "^1.0.0",
|
|
67
75
|
"drizzle-kit": "^0.30.0",
|
|
68
76
|
"drizzle-orm": "^0.45.0",
|
|
69
|
-
"
|
|
77
|
+
"hono": "^4.0.0",
|
|
78
|
+
"nodemailer": ">=6 <10",
|
|
70
79
|
"postgres": ">=3.4.0",
|
|
71
|
-
"typescript": "
|
|
80
|
+
"typescript": ">=5",
|
|
81
|
+
"zod": "^4.4.3"
|
|
72
82
|
},
|
|
73
83
|
"peerDependenciesMeta": {
|
|
74
84
|
"@electric-sql/pglite": {
|
|
@@ -85,6 +95,9 @@
|
|
|
85
95
|
},
|
|
86
96
|
"postgres": {
|
|
87
97
|
"optional": true
|
|
98
|
+
},
|
|
99
|
+
"typescript": {
|
|
100
|
+
"optional": true
|
|
88
101
|
}
|
|
89
102
|
}
|
|
90
103
|
}
|
package/src/config.ts
CHANGED
|
@@ -3,12 +3,12 @@ import { betterAuth } from 'better-auth'
|
|
|
3
3
|
import { z } from 'zod'
|
|
4
4
|
|
|
5
5
|
import type { TableAccessInput } from './access'
|
|
6
|
+
import type { DatabaseAdapter } from './database/adapter'
|
|
7
|
+
import type { EmailConfigInput } from './email'
|
|
6
8
|
import type { IdempotencyConfig } from './idempotency'
|
|
7
9
|
import type { RateLimitConfig } from './rate-limit'
|
|
8
10
|
|
|
9
11
|
import { validateEnv, type BaseEnv, type EnvConfigInput } from './env'
|
|
10
|
-
import type { EmailConfigInput } from './email'
|
|
11
|
-
|
|
12
12
|
import {
|
|
13
13
|
resolveBuckets,
|
|
14
14
|
type ResolvedStorageBuckets,
|
|
@@ -22,9 +22,10 @@ export type BetterAuthConfig = Omit<
|
|
|
22
22
|
|
|
23
23
|
export const BunderstackOptionsSchema = z.object({
|
|
24
24
|
schema: z.record(z.string(), z.unknown()),
|
|
25
|
-
access: z.record(z.string(), z.
|
|
25
|
+
access: z.record(z.string(), z.unknown()).optional(),
|
|
26
26
|
database: z
|
|
27
27
|
.object({
|
|
28
|
+
adapter: z.unknown(),
|
|
28
29
|
url: z.string().optional(),
|
|
29
30
|
authToken: z.string().optional(),
|
|
30
31
|
migrations: z.string().optional(),
|
|
@@ -82,10 +83,24 @@ export type BunderstackConfig<
|
|
|
82
83
|
TEnv extends EnvConfigInput | undefined = EnvConfigInput | undefined,
|
|
83
84
|
> = Omit<
|
|
84
85
|
z.input<typeof BunderstackOptionsSchema>,
|
|
85
|
-
|
|
86
|
+
| 'schema'
|
|
87
|
+
| 'access'
|
|
88
|
+
| 'auth'
|
|
89
|
+
| 'storage'
|
|
90
|
+
| 'env'
|
|
91
|
+
| 'email'
|
|
92
|
+
| 'trpc'
|
|
93
|
+
| 'jobs'
|
|
94
|
+
| 'database'
|
|
86
95
|
> & {
|
|
87
96
|
schema: TSchema
|
|
88
97
|
access?: TAccess
|
|
98
|
+
database: {
|
|
99
|
+
adapter: DatabaseAdapter
|
|
100
|
+
url?: string
|
|
101
|
+
authToken?: string
|
|
102
|
+
migrations?: string
|
|
103
|
+
}
|
|
89
104
|
auth?: BetterAuthConfig
|
|
90
105
|
storage?: TStorage
|
|
91
106
|
env?: TEnv
|
|
@@ -106,7 +121,12 @@ export type BunderstackConfig<
|
|
|
106
121
|
}
|
|
107
122
|
|
|
108
123
|
export type ResolvedConfig = {
|
|
109
|
-
database: {
|
|
124
|
+
database: {
|
|
125
|
+
adapter: DatabaseAdapter
|
|
126
|
+
url: string
|
|
127
|
+
authToken?: string
|
|
128
|
+
migrations: string
|
|
129
|
+
}
|
|
110
130
|
auth: BetterAuthConfig
|
|
111
131
|
storage: ResolvedStorageBuckets
|
|
112
132
|
realtime?:
|
|
@@ -134,12 +154,22 @@ export function resolveConfig<TSchema extends Record<string, unknown>>(
|
|
|
134
154
|
const resolvedEnv =
|
|
135
155
|
env ?? validateEnv(options.env as EnvConfigInput | undefined)
|
|
136
156
|
|
|
157
|
+
const adapter = options.database?.adapter
|
|
158
|
+
if (!adapter) {
|
|
159
|
+
throw new Error('[bunderstack] database.adapter is required')
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const defaultUrl =
|
|
163
|
+
adapter.dialect === 'sqlite' ? 'file:./data.db' : 'file:./data.pglite'
|
|
164
|
+
|
|
137
165
|
return {
|
|
138
166
|
database: {
|
|
167
|
+
adapter,
|
|
139
168
|
url:
|
|
140
169
|
platformSource['BUNDERSTACK_DATABASE_URL'] ??
|
|
141
170
|
parsed.database?.url ??
|
|
142
|
-
resolvedEnv.DATABASE_URL
|
|
171
|
+
resolvedEnv.DATABASE_URL ??
|
|
172
|
+
defaultUrl,
|
|
143
173
|
authToken:
|
|
144
174
|
platformSource['BUNDERSTACK_DATABASE_AUTH_TOKEN'] ??
|
|
145
175
|
parsed.database?.authToken ??
|
package/src/crud.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { eq, getTableColumns, getTableName, isTable } from 'drizzle-orm'
|
|
|
2
2
|
import { Hono } from 'hono'
|
|
3
3
|
|
|
4
4
|
import type { AnyDb } from './dialect'
|
|
5
|
-
import type {
|
|
5
|
+
import type { RealtimeFacade } from './realtime/facade'
|
|
6
6
|
|
|
7
7
|
import {
|
|
8
8
|
checkAccess,
|
|
@@ -29,11 +29,13 @@ import {
|
|
|
29
29
|
import { executeList, parseListParams } from './list-query'
|
|
30
30
|
import { buildScopeWhere } from './scope'
|
|
31
31
|
|
|
32
|
-
export type CrudRouterOptions
|
|
32
|
+
export type CrudRouterOptions<
|
|
33
|
+
TSchema extends Record<string, unknown> = Record<string, unknown>,
|
|
34
|
+
> = {
|
|
33
35
|
auth?: AuthSessionResolver
|
|
34
36
|
access: ResolvedAccess
|
|
35
37
|
idempotency?: boolean | IdempotencyConfig
|
|
36
|
-
|
|
38
|
+
realtime?: RealtimeFacade<TSchema>
|
|
37
39
|
}
|
|
38
40
|
|
|
39
41
|
function tableEntryForName(
|
|
@@ -63,10 +65,10 @@ async function enforce(
|
|
|
63
65
|
export function buildCrudRouter<TSchema extends Record<string, unknown>>(
|
|
64
66
|
schema: TSchema,
|
|
65
67
|
db: AnyDb,
|
|
66
|
-
options: CrudRouterOptions
|
|
68
|
+
options: CrudRouterOptions<TSchema>,
|
|
67
69
|
): Hono {
|
|
68
70
|
const router = new Hono()
|
|
69
|
-
const { auth, access,
|
|
71
|
+
const { auth, access, realtime } = options
|
|
70
72
|
const idempotency = resolveIdempotencyConfig(options.idempotency)
|
|
71
73
|
|
|
72
74
|
const scopeFor = (
|
|
@@ -243,7 +245,7 @@ export function buildCrudRouter<TSchema extends Record<string, unknown>>(
|
|
|
243
245
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
244
246
|
const rows = await (db as any).insert(table).values(stamped).returning()
|
|
245
247
|
const created = rows[0]
|
|
246
|
-
void
|
|
248
|
+
void realtime?.publish(table as never, 'create', created as never)
|
|
247
249
|
|
|
248
250
|
if (idempotency && idempotencyKey) {
|
|
249
251
|
await storeIdempotency(
|
|
@@ -329,7 +331,7 @@ export function buildCrudRouter<TSchema extends Record<string, unknown>>(
|
|
|
329
331
|
if (!rows[0]) {
|
|
330
332
|
return apiError(c, ErrorCode.NOT_FOUND, 'Not found', 404)
|
|
331
333
|
}
|
|
332
|
-
void
|
|
334
|
+
void realtime?.publish(table as never, 'update', rows[0] as never)
|
|
333
335
|
return c.json(rows[0])
|
|
334
336
|
})
|
|
335
337
|
|
|
@@ -375,11 +377,7 @@ export function buildCrudRouter<TSchema extends Record<string, unknown>>(
|
|
|
375
377
|
|
|
376
378
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
377
379
|
await (db as any).delete(table).where(eq(idCol as any, id))
|
|
378
|
-
void
|
|
379
|
-
name,
|
|
380
|
-
'delete',
|
|
381
|
-
existing[0] as Record<string, unknown>,
|
|
382
|
-
)
|
|
380
|
+
void realtime?.publish(table as never, 'delete', existing[0] as never)
|
|
383
381
|
return new Response(null, { status: 204 })
|
|
384
382
|
})
|
|
385
383
|
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { DbFor, Driver } from '../db'
|
|
2
|
+
import type { AnyDb, Dialect } from '../dialect'
|
|
3
|
+
|
|
4
|
+
export type DatabaseConnection = {
|
|
5
|
+
url: string
|
|
6
|
+
authToken?: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export type DatabaseConnectOptions = { introspect: boolean }
|
|
10
|
+
|
|
11
|
+
export type DatabaseConnectionResult<TSchema extends Record<string, unknown>> =
|
|
12
|
+
{
|
|
13
|
+
db: DbFor<TSchema>
|
|
14
|
+
close?: () => void | Promise<void>
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type DatabaseAdapter = {
|
|
18
|
+
readonly dialect: Dialect
|
|
19
|
+
readonly driver: Driver
|
|
20
|
+
connect<TSchema extends Record<string, unknown>>(
|
|
21
|
+
schema: TSchema,
|
|
22
|
+
connection: DatabaseConnection,
|
|
23
|
+
options: DatabaseConnectOptions,
|
|
24
|
+
): Promise<DatabaseConnectionResult<TSchema>>
|
|
25
|
+
migrate(db: AnyDb, migrationsFolder: string): Promise<void>
|
|
26
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { drizzle } from 'drizzle-orm/bun-sql'
|
|
2
|
+
import { migrate } from 'drizzle-orm/bun-sql/migrator'
|
|
3
|
+
|
|
4
|
+
import type { DatabaseAdapter } from './adapter'
|
|
5
|
+
|
|
6
|
+
export function bunSql(): DatabaseAdapter {
|
|
7
|
+
return {
|
|
8
|
+
dialect: 'pg',
|
|
9
|
+
driver: 'bun-sql',
|
|
10
|
+
async connect(schema, { url }, { introspect }) {
|
|
11
|
+
if (introspect) return { db: drizzle.mock({ schema }) as never }
|
|
12
|
+
|
|
13
|
+
if (!url.startsWith('postgres://') && !url.startsWith('postgresql://')) {
|
|
14
|
+
throw new Error('[bunderstack] bunSql adapter requires a Postgres URL')
|
|
15
|
+
}
|
|
16
|
+
const db = drizzle(url, { schema })
|
|
17
|
+
return { db: db as never, close: () => db.$client.close() }
|
|
18
|
+
},
|
|
19
|
+
async migrate(db, migrationsFolder) {
|
|
20
|
+
await migrate(db as never, { migrationsFolder })
|
|
21
|
+
},
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { drizzle } from 'drizzle-orm/libsql'
|
|
2
|
+
import { migrate } from 'drizzle-orm/libsql/migrator'
|
|
3
|
+
|
|
4
|
+
import type { DatabaseAdapter } from './adapter'
|
|
5
|
+
|
|
6
|
+
export function libsql(): DatabaseAdapter {
|
|
7
|
+
return {
|
|
8
|
+
dialect: 'sqlite',
|
|
9
|
+
driver: 'libsql',
|
|
10
|
+
async connect(schema, connection, { introspect }) {
|
|
11
|
+
if (introspect) return { db: drizzle.mock({ schema }) as never }
|
|
12
|
+
|
|
13
|
+
if (
|
|
14
|
+
connection.url.startsWith('postgres://') ||
|
|
15
|
+
connection.url.startsWith('postgresql://')
|
|
16
|
+
) {
|
|
17
|
+
throw new Error(
|
|
18
|
+
'[bunderstack] libsql adapter cannot connect to a Postgres URL',
|
|
19
|
+
)
|
|
20
|
+
}
|
|
21
|
+
const db = drizzle({ connection, schema })
|
|
22
|
+
return { db: db as never, close: () => db.$client.close() }
|
|
23
|
+
},
|
|
24
|
+
async migrate(db, migrationsFolder) {
|
|
25
|
+
await migrate(db as never, { migrationsFolder })
|
|
26
|
+
},
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { drizzle } from 'drizzle-orm/pglite'
|
|
2
|
+
import { migrate } from 'drizzle-orm/pglite/migrator'
|
|
3
|
+
import { mkdir } from 'node:fs/promises'
|
|
4
|
+
|
|
5
|
+
import type { DatabaseAdapter } from './adapter'
|
|
6
|
+
|
|
7
|
+
const rawPath = (url: string) =>
|
|
8
|
+
url.startsWith('file:') ? url.slice('file:'.length) : url
|
|
9
|
+
|
|
10
|
+
export function pglite(): DatabaseAdapter {
|
|
11
|
+
return {
|
|
12
|
+
dialect: 'pg',
|
|
13
|
+
driver: 'pglite',
|
|
14
|
+
async connect(schema, { url }, { introspect }) {
|
|
15
|
+
if (introspect) return { db: drizzle.mock({ schema }) as never }
|
|
16
|
+
|
|
17
|
+
if (url.startsWith('postgres://') || url.startsWith('postgresql://')) {
|
|
18
|
+
throw new Error(
|
|
19
|
+
'[bunderstack] pglite adapter cannot connect to a Postgres URL',
|
|
20
|
+
)
|
|
21
|
+
}
|
|
22
|
+
const raw = rawPath(url)
|
|
23
|
+
const dataDir = raw === ':memory:' ? 'memory://' : raw
|
|
24
|
+
if (!dataDir.startsWith('memory://'))
|
|
25
|
+
await mkdir(dataDir, { recursive: true })
|
|
26
|
+
const db = drizzle(dataDir, { schema })
|
|
27
|
+
return { db: db as never, close: () => db.$client.close() }
|
|
28
|
+
},
|
|
29
|
+
async migrate(db, migrationsFolder) {
|
|
30
|
+
await migrate(db as never, { migrationsFolder })
|
|
31
|
+
},
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { drizzle } from 'drizzle-orm/postgres-js'
|
|
2
|
+
import { migrate } from 'drizzle-orm/postgres-js/migrator'
|
|
3
|
+
|
|
4
|
+
import type {
|
|
5
|
+
DatabaseAdapter,
|
|
6
|
+
DatabaseConnection,
|
|
7
|
+
DatabaseConnectOptions,
|
|
8
|
+
} from './adapter'
|
|
9
|
+
|
|
10
|
+
export function postgresJs(): DatabaseAdapter {
|
|
11
|
+
return {
|
|
12
|
+
dialect: 'pg',
|
|
13
|
+
driver: 'postgres-js',
|
|
14
|
+
async connect<TSchema extends Record<string, unknown>>(
|
|
15
|
+
schema: TSchema,
|
|
16
|
+
{ url }: DatabaseConnection,
|
|
17
|
+
{ introspect }: DatabaseConnectOptions,
|
|
18
|
+
) {
|
|
19
|
+
if (introspect) return { db: drizzle.mock({ schema }) as never }
|
|
20
|
+
|
|
21
|
+
if (!url.startsWith('postgres://') && !url.startsWith('postgresql://')) {
|
|
22
|
+
throw new Error(
|
|
23
|
+
'[bunderstack] postgresJs adapter requires a Postgres URL',
|
|
24
|
+
)
|
|
25
|
+
}
|
|
26
|
+
const db = drizzle<TSchema>({ connection: url, schema })
|
|
27
|
+
return { db: db as never, close: () => db.$client.end() }
|
|
28
|
+
},
|
|
29
|
+
async migrate(db, migrationsFolder) {
|
|
30
|
+
await migrate(db as never, { migrationsFolder })
|
|
31
|
+
},
|
|
32
|
+
}
|
|
33
|
+
}
|
package/src/db.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
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
1
|
import type { LibSQLDatabase } from 'drizzle-orm/libsql'
|
|
5
2
|
import type { PgDatabase, PgQueryResultHKT, PgTable } from 'drizzle-orm/pg-core'
|
|
6
3
|
|
|
7
|
-
import {
|
|
8
|
-
|
|
4
|
+
import type {
|
|
5
|
+
DatabaseAdapter,
|
|
6
|
+
DatabaseConnection,
|
|
7
|
+
DatabaseConnectionResult,
|
|
8
|
+
} from './database/adapter'
|
|
9
9
|
import type { Dialect } from './dialect'
|
|
10
10
|
|
|
11
11
|
export type Driver = 'libsql' | 'pglite' | 'bun-sql' | 'postgres-js'
|
|
@@ -20,84 +20,42 @@ export type DbFor<TSchema extends Record<string, unknown>> = [
|
|
|
20
20
|
const PG_SERVER_RE = /^postgres(ql)?:\/\//
|
|
21
21
|
const LIBSQL_REMOTE_RE = /^(libsql|wss?|https?):\/\//
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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>>(
|
|
35
|
-
schema: TSchema,
|
|
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)) {
|
|
23
|
+
export function validateDatabaseUrl(url: string, dialect: Dialect) {
|
|
24
|
+
if (dialect === 'sqlite') {
|
|
25
|
+
if (PG_SERVER_RE.test(url)) {
|
|
40
26
|
throw new Error(
|
|
41
27
|
'[bunderstack] DATABASE_URL is a Postgres URL but the schema uses sqliteTable. ' +
|
|
42
28
|
'Define the schema with drizzle-orm/pg-core, or point DATABASE_URL at a SQLite database.',
|
|
43
29
|
)
|
|
44
30
|
}
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
'
|
|
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'
|
|
31
|
+
} else if (dialect === 'pg') {
|
|
32
|
+
if (LIBSQL_REMOTE_RE.test(url)) {
|
|
33
|
+
throw new Error(
|
|
34
|
+
'[bunderstack] DATABASE_URL looks like a libsql/Turso URL but the schema uses pgTable. ' +
|
|
35
|
+
'Set DATABASE_URL=postgres://… (or leave it unset for local PGlite).',
|
|
68
36
|
)
|
|
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
37
|
}
|
|
85
38
|
}
|
|
39
|
+
}
|
|
86
40
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
41
|
+
export async function createDb<TSchema extends Record<string, unknown>>(
|
|
42
|
+
schema: TSchema,
|
|
43
|
+
cfg: DatabaseConnection & {
|
|
44
|
+
adapter: DatabaseAdapter
|
|
45
|
+
dialect: Dialect
|
|
46
|
+
introspect?: boolean
|
|
47
|
+
},
|
|
48
|
+
): Promise<DatabaseConnectionResult<TSchema> & { driver: Driver }> {
|
|
49
|
+
if (cfg.adapter.dialect !== cfg.dialect) {
|
|
50
|
+
throw new Error(
|
|
51
|
+
`[bunderstack] database adapter dialect ${cfg.adapter.dialect} does not match ${cfg.dialect} schema`,
|
|
52
|
+
)
|
|
92
53
|
}
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
54
|
+
if (!cfg.introspect) validateDatabaseUrl(cfg.url, cfg.dialect)
|
|
55
|
+
const result = await cfg.adapter.connect(
|
|
56
|
+
schema,
|
|
57
|
+
{ url: cfg.url, authToken: cfg.authToken },
|
|
58
|
+
{ introspect: cfg.introspect ?? false },
|
|
98
59
|
)
|
|
99
|
-
return {
|
|
100
|
-
db: drizzle(dataDir, { schema }) as unknown as DbFor<TSchema>,
|
|
101
|
-
driver: 'pglite',
|
|
102
|
-
}
|
|
60
|
+
return { ...result, driver: cfg.adapter.driver }
|
|
103
61
|
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import nodemailer from 'nodemailer'
|
|
2
|
+
|
|
3
|
+
import type { EmailAdapter, EmailMessage } from '../email'
|
|
4
|
+
|
|
5
|
+
type SmtpTransport = {
|
|
6
|
+
sendMail(message: Record<string, unknown>): Promise<{ messageId?: string }>
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function createSmtpAdapter(
|
|
10
|
+
options: { url: string },
|
|
11
|
+
createTransport: (url: string) => SmtpTransport = (url) =>
|
|
12
|
+
nodemailer.createTransport(url) as SmtpTransport,
|
|
13
|
+
): EmailAdapter {
|
|
14
|
+
const toArray = (v: string | string[] | undefined) =>
|
|
15
|
+
v === undefined ? undefined : Array.isArray(v) ? v : [v]
|
|
16
|
+
|
|
17
|
+
let transportPromise: Promise<{
|
|
18
|
+
sendMail(opts: Record<string, unknown>): Promise<{ messageId?: string }>
|
|
19
|
+
}> | null = null
|
|
20
|
+
|
|
21
|
+
const getTransport = () => {
|
|
22
|
+
transportPromise ??= Promise.resolve(createTransport(options.url))
|
|
23
|
+
return transportPromise
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return {
|
|
27
|
+
async send(msg) {
|
|
28
|
+
const transport = await getTransport()
|
|
29
|
+
const info = await transport.sendMail({
|
|
30
|
+
from: msg.from,
|
|
31
|
+
to: toArray(msg.to)!.join(', '),
|
|
32
|
+
subject: msg.subject,
|
|
33
|
+
html: msg.html,
|
|
34
|
+
text: msg.text,
|
|
35
|
+
replyTo: msg.replyTo,
|
|
36
|
+
cc: toArray(msg.cc)?.join(', '),
|
|
37
|
+
bcc: toArray(msg.bcc)?.join(', '),
|
|
38
|
+
})
|
|
39
|
+
return { id: info.messageId }
|
|
40
|
+
},
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const smtp = (options: { url: string }): EmailAdapter =>
|
|
45
|
+
createSmtpAdapter(options)
|