bunderstack 0.16.0 → 0.17.0-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,164 +1,51 @@
1
1
  # bunderstack
2
2
 
3
- A batteries-included backend framework for Bun. Point it at a Drizzle schema
4
- and get CRUD APIs, auth, file storage, realtime, typed custom endpoints
5
- (tRPC), email, and validated env — all from a single config object and a
6
- single `Request → Response` handler.
3
+ The server package for Bunderstack's unified, type-safe oRPC backend.
7
4
 
8
5
  ```sh
9
- bun add bunderstack better-auth drizzle-orm hono zod @libsql/client
6
+ bun add bunderstack better-auth drizzle-orm valibot @libsql/client
10
7
  ```
11
8
 
12
9
  ```ts
13
10
  import { createBunderstack } from 'bunderstack'
14
11
  import { libsql } from 'bunderstack/database/libsql'
12
+ import * as v from 'valibot'
15
13
  import * as schema from './schema'
16
14
 
17
- const app = await createBunderstack({
15
+ export const app = await createBunderstack({
18
16
  schema,
19
- database: {
20
- adapter: libsql(),
21
- url: 'file:./data.db',
22
- },
23
- auth: { emailAndPassword: { enabled: true } },
24
- access: {
25
- posts: { ownerColumn: 'userId', list: 'public', create: 'authenticated' },
26
- },
17
+ database: { adapter: libsql(), url: 'file:./data.db' },
18
+ access: { posts: { crud: true } },
19
+ realtime: true,
20
+ api: (o) => ({
21
+ ping: o.public
22
+ .route({ method: 'GET', path: '/api/ping' })
23
+ .input(v.optional(v.object({})))
24
+ .handler(() => ({ ok: true })),
25
+ }),
27
26
  })
28
27
 
29
28
  Bun.serve({ fetch: app.handler })
29
+ export type App = typeof app
30
30
  ```
31
31
 
32
- Full documentation and examples:
33
- [github.com/kirill-dev-pro/bunderstack](https://github.com/kirill-dev-pro/bunderstack)
32
+ CRUD, custom procedures, webhooks, file buckets, health, and the Publisher
33
+ event iterator form one router. Validation accepts Standard Schema; internal
34
+ generated schemas use Valibot. OpenAPI is opt-in with `openapi: true`.
34
35
 
35
- ## Platform deployment contract
36
-
37
- Deployment platforms (like Bunderhost) integrate with any bunderstack app
38
- through env vars alone — no code changes required.
39
-
40
- ### Overrides (beat code-level config)
41
-
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 |
51
-
52
- Plain `DATABASE_URL` / `S3_*` vars keep their usual role: fallbacks that
53
- code-level config wins over.
54
-
55
- ### Committed deployment blueprint
56
-
57
- Generate a deterministic, provider-neutral declaration that a host can read
58
- without importing the application at deploy time. Version 1 supports TanStack
59
- Start apps and records database tables and migration mode, storage buckets,
60
- environment requirements, realtime, jobs, cron, and maintenance schedules.
61
-
62
- ```sh
63
- bunx bunderstack blueprint
64
- bunx bunderstack blueprint --check
65
- ```
66
-
67
- The command imports `src/bunderstack.ts` by default (or
68
- `package.json#bunderstack.entry`) with `BUNDERSTACK_INTROSPECT=1`, so it never
69
- opens external database, storage, or realtime connections. Commit the generated
70
- `bunderstack.blueprint.yaml`; CI should run the `--check` form. The public
71
- `bunderstack/blueprint` module exposes the strict parser and conversion helpers
72
- for hosts and other tooling.
73
-
74
- Real database clients belong to the app. Call `await app.close()` when a
75
- standalone process or test is finished; it closes the real libSQL, PGlite,
76
- postgres.js, or Bun SQL client selected by `database.adapter`. Introspection
77
- mocks own no client, so there is nothing to close for that database path.
78
-
79
- ### Background runtime
80
-
81
- Declaring jobs does not start a worker. Queue jobs (`j.job()`) are processed by
82
- an explicit worker process:
83
-
84
- ```ts
85
- import { app } from './bunderstack'
86
-
87
- await app.runWorker()
88
- ```
89
-
90
- Most applications need none of this: background work runs in-process by
91
- default. Set `BUNDERSTACK_ROLE` to `web` or `worker` to split it across
92
- processes without changing code.
93
-
94
- If a queue handler calls `ctx.realtime.publish()`, the web and worker processes
95
- must share a realtime transport. Configure `REDIS_URL` (or
96
- `realtime: { redis: "redis://..." }`). `realtime: true` without Redis uses a
97
- process-local memory broker and is suitable only when the worker is embedded
98
- with `app.startWorker()`.
99
-
100
- Since 0.9.0, `app.runWorker()` rejects that unsafe combination by default. If
101
- queue handlers never publish realtime events, acknowledge the process-local
102
- behavior with `app.runWorker({ allowProcessLocalRealtime: true })`.
103
-
104
- Inspect the active runtime with `app.realtime.transport` (`'disabled'`,
105
- `'memory'`, or `'redis'`). The generated blueprint declares only whether
106
- realtime is required; the host chooses its shared transport and injects its
107
- runtime configuration.
108
-
109
- ```ts
110
- const app = await createBunderstack({
111
- // ...
112
- realtime: { redis: process.env.REDIS_URL! },
113
- })
114
- ```
115
-
116
- ```bash
117
- REDIS_URL=redis://localhost:6379 bun src/server.ts
118
- REDIS_URL=redis://localhost:6379 bun src/worker.ts
119
- ```
120
-
121
- Cron tasks (`j.cron()`) are materialized as job rows keyed by their slot, so
122
- they run through the same loop, retries, and timeouts as queue jobs. There is
123
- no separate cron process and no signed dispatch endpoint.
124
-
125
- ### Publishing custom writes to realtime
126
-
127
- Generated CRUD publishes automatically. Writes made directly through `app.db`
128
- or `ctx.db` are explicit: publish the complete row returned by Drizzle after the
129
- write commits.
36
+ Generated CRUD writes publish automatically. After a custom write commits,
37
+ publish its complete returned row with:
130
38
 
131
39
  ```ts
132
- const [avatar] = await ctx.db
133
- .update(schema.avatars)
134
- .set({ status: 'completed' })
135
- .where(eq(schema.avatars.id, avatarId))
136
- .returning()
137
-
138
- await ctx.realtime.publish(schema.avatars, 'update', avatar)
40
+ await context.realtime.publish(schema.posts, 'update', post)
139
41
  ```
140
42
 
141
- The same typed facade is available as `app.realtime`, in tRPC context, and in
142
- queue-job and cron context. Passing the Drizzle table makes a table-name typo a
143
- type error and constrains the record to that table's select model.
144
-
145
- Publish after an enclosing transaction resolves, not from inside it. The full
146
- row is required because realtime access filtering may inspect its `id`, owner,
147
- or read-scope columns. Subscriber access checks, Redis fan-out, and replay are
148
- applied automatically by the existing broker. When server realtime is not
149
- configured, `realtime.enabled` is `false` and `publish()` is a no-op.
43
+ Use the in-memory Publisher for one process or configure
44
+ `realtime: { redis: process.env.REDIS_URL! }` for multi-process delivery and
45
+ replay. Deployment metadata is generated with `bunx bunderstack blueprint`.
150
46
 
151
- ## Shipping TypeScript source
152
-
153
- This package publishes raw TypeScript (`exports` point at `.ts` files). Bun
154
- consumes it natively. If a Node-based bundler or SSR server processes it,
155
- make sure the package is bundled rather than externalized — e.g. in Vite:
156
-
157
- ```ts
158
- ssr: {
159
- noExternal: [/^bunderstack/]
160
- }
161
- ```
47
+ See the [workspace documentation](../../README.md) for webhooks, clients,
48
+ storage, collections, lifecycle, and complete examples.
162
49
 
163
50
  ## License
164
51
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "bunderstack",
3
- "version": "0.16.0",
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.",
3
+ "version": "0.17.0-beta.2",
4
+ "description": "Batteries-included backend framework for Bun: type-safe oRPC APIs, auth, storage, realtime, jobs, email, and validated env from one config.",
5
5
  "keywords": [
6
6
  "backend",
7
7
  "better-auth",
@@ -9,9 +9,9 @@
9
9
  "crud",
10
10
  "drizzle",
11
11
  "framework",
12
- "hono",
12
+ "orpc",
13
13
  "realtime",
14
- "trpc"
14
+ "typesafe"
15
15
  ],
16
16
  "homepage": "https://github.com/kirill-dev-pro/bunderstack#readme",
17
17
  "license": "MIT",
@@ -26,6 +26,7 @@
26
26
  "files": [
27
27
  "src",
28
28
  "!src/**/*.test.ts",
29
+ "!src/**/*.types.ts",
29
30
  "README.md",
30
31
  "LICENSE"
31
32
  ],
@@ -47,9 +48,9 @@
47
48
  "./typeid/pg": "./src/typeid-pg.ts",
48
49
  "./env": "./src/env.ts",
49
50
  "./blueprint": "./src/blueprint.ts",
50
- "./trpc": "./src/trpc.ts",
51
51
  "./cron": "./src/cron.ts",
52
- "./email/smtp": "./src/email/smtp.ts"
52
+ "./email/smtp": "./src/email/smtp.ts",
53
+ "./api": "./src/api/types.ts"
53
54
  },
54
55
  "scripts": {
55
56
  "test": "bun test",
@@ -59,32 +60,41 @@
59
60
  "db:migrate": "drizzle-kit migrate"
60
61
  },
61
62
  "dependencies": {
62
- "superjson": "^2.2.0",
63
+ "@standard-schema/spec": "1.1.0",
64
+ "valibot": "1.4.2",
63
65
  "yaml": "^2.9.0"
64
66
  },
65
67
  "devDependencies": {
66
68
  "@electric-sql/pglite": ">=0.3.0",
67
69
  "@libsql/client": ">=0.14.0",
68
- "@trpc/server": "^11.0.0",
70
+ "@orpc/bun": "2.0.0-beta.26",
71
+ "@orpc/json-schema": "2.0.0-beta.26",
72
+ "@orpc/openapi": "2.0.0-beta.26",
73
+ "@orpc/publisher": "2.0.0-beta.26",
74
+ "@orpc/server": "2.0.0-beta.26",
75
+ "@orpc/valibot": "2.0.0-beta.26",
69
76
  "@types/nodemailer": "^6",
70
77
  "better-auth": "^1.0.0",
71
78
  "drizzle-kit": "^0.30.0",
72
79
  "drizzle-orm": "^0.45.0",
73
- "hono": "^4.0.0",
74
- "zod": "^4.4.3"
80
+ "drizzle-valibot": "0.4.2"
75
81
  },
76
82
  "peerDependencies": {
77
83
  "@electric-sql/pglite": ">=0.3.0",
78
84
  "@libsql/client": ">=0.14.0",
79
- "@trpc/server": "^11.0.0",
85
+ "@orpc/bun": "2.0.0-beta.26",
86
+ "@orpc/json-schema": "2.0.0-beta.26",
87
+ "@orpc/openapi": "2.0.0-beta.26",
88
+ "@orpc/publisher": "2.0.0-beta.26",
89
+ "@orpc/server": "2.0.0-beta.26",
90
+ "@orpc/valibot": "2.0.0-beta.26",
80
91
  "better-auth": "^1.0.0",
81
92
  "drizzle-kit": "^0.30.0",
82
93
  "drizzle-orm": "^0.45.0",
83
- "hono": "^4.0.0",
94
+ "drizzle-valibot": "0.4.2",
84
95
  "nodemailer": ">=6 <10",
85
96
  "postgres": ">=3.4.0",
86
- "typescript": ">=5",
87
- "zod": "^4.4.3"
97
+ "typescript": ">=5"
88
98
  },
89
99
  "peerDependenciesMeta": {
90
100
  "@electric-sql/pglite": {
package/src/access.ts CHANGED
@@ -38,16 +38,6 @@ export type ScopeResolver = (ctx: AccessContext) => ScopeMap
38
38
 
39
39
  export type CrudOperation = 'list' | 'get' | 'create' | 'update' | 'delete'
40
40
 
41
- const RESERVED_LIST_PARAMS = new Set([
42
- 'limit',
43
- 'offset',
44
- 'sort',
45
- 'order',
46
- 'q',
47
- 'cursor',
48
- 'count',
49
- ])
50
-
51
41
  export type SortOrder = 'asc' | 'desc'
52
42
 
53
43
  export type DefaultSort = {
@@ -65,6 +55,8 @@ export type TableAccessInput = {
65
55
  create?: OperationRule
66
56
  update?: OperationRule
67
57
  delete?: OperationRule
58
+ /** Explicit write allowlist. Entries override matching system-readonly
59
+ * defaults such as `updatedAt`; `id` remains immutable on update. */
68
60
  writableColumns?: string[]
69
61
  readonlyColumns?: string[]
70
62
  /** Columns matched by `?q=` on list — opt-in; omitted columns are never searched. */
@@ -174,11 +166,6 @@ function resolveListAccess(
174
166
 
175
167
  const filterableColumns = input.filterableColumns ?? []
176
168
  for (const col of filterableColumns) {
177
- if (RESERVED_LIST_PARAMS.has(col)) {
178
- throw new Error(
179
- `[bunderstack] filterableColumns cannot include reserved query param "${col}"`,
180
- )
181
- }
182
169
  if (!columns.includes(col)) {
183
170
  throw new Error(
184
171
  `[bunderstack] filterableColumns references unknown column "${col}"`,
@@ -187,11 +174,6 @@ function resolveListAccess(
187
174
  }
188
175
 
189
176
  for (const col of sortableColumns) {
190
- if (RESERVED_LIST_PARAMS.has(col)) {
191
- throw new Error(
192
- `[bunderstack] sortableColumns cannot include reserved query param "${col}"`,
193
- )
194
- }
195
177
  if (!columns.includes(col)) {
196
178
  throw new Error(
197
179
  `[bunderstack] sortableColumns references unknown column "${col}"`,
@@ -208,6 +190,7 @@ function resolveDefaults(
208
190
  columns: string[],
209
191
  ): Omit<ResolvedTableAccess, 'tableKey' | 'tableName' | 'enabled'> {
210
192
  const listAccess = resolveListAccess(input, columns)
193
+ const explicitlyWritable = new Set(input.writableColumns ?? [])
211
194
  return {
212
195
  ownerColumn,
213
196
  list: input.list ?? 'public',
@@ -217,7 +200,9 @@ function resolveDefaults(
217
200
  delete: input.delete ?? (ownerColumn ? 'owner' : 'deny'),
218
201
  writableColumns: input.writableColumns,
219
202
  readonlyColumns: [
220
- ...DEFAULT_READONLY,
203
+ ...DEFAULT_READONLY.filter(
204
+ (column) => column === 'id' || !explicitlyWritable.has(column),
205
+ ),
221
206
  ...(input.readonlyColumns ?? []),
222
207
  ...(ownerColumn ? [ownerColumn] : []),
223
208
  ],
@@ -0,0 +1,52 @@
1
+ import { os, type AnyRouter } from '@orpc/server'
2
+
3
+ import type { ApiContext } from './context'
4
+
5
+ import {
6
+ BUNDERSTACK_ERRORS,
7
+ BunderstackError,
8
+ mapBunderstackErrors,
9
+ } from '../errors'
10
+ export type { ProtectedContextAdditions } from './types'
11
+
12
+ export function createApiBuilder<
13
+ TSchema extends Record<string, unknown> = Record<string, unknown>,
14
+ TEnv = Record<string, unknown>,
15
+ >() {
16
+ const base = os
17
+ .$context<ApiContext<TSchema, TEnv>>()
18
+ .errors(BUNDERSTACK_ERRORS)
19
+ .use(mapBunderstackErrors)
20
+
21
+ const protectedProc = base.use(async ({ context, next }) => {
22
+ const session = await context.getSession()
23
+ if (!session.user) {
24
+ throw new BunderstackError('UNAUTHORIZED', 'Authentication required')
25
+ }
26
+ return next({
27
+ context: {
28
+ user: session.user,
29
+ session: {
30
+ activeOrganizationId: session.activeOrganizationId,
31
+ },
32
+ },
33
+ })
34
+ })
35
+
36
+ return {
37
+ public: base,
38
+ protected: protectedProc,
39
+ webhook: base,
40
+ }
41
+ }
42
+
43
+ export type BunderstackApiBuilder<
44
+ TSchema extends Record<string, unknown>,
45
+ TEnv = Record<string, unknown>,
46
+ > = ReturnType<typeof createApiBuilder<TSchema, TEnv>>
47
+
48
+ export type ApiFactory<
49
+ TSchema extends Record<string, unknown>,
50
+ TEnv,
51
+ TCustomApiRouter extends AnyRouter,
52
+ > = (builder: BunderstackApiBuilder<TSchema, TEnv>) => TCustomApiRouter
@@ -0,0 +1,83 @@
1
+ import type { AccessUser, AuthSessionResolver } from '../access'
2
+ import type { DbFor } from '../db'
3
+ import type { EmailFacade } from '../email'
4
+ import type { AuthInstance, StorageFacade } from '../index'
5
+ import type { JobsRuntimeFacade } from '../jobs/define'
6
+ import type { RealtimeFacade } from '../realtime/facade'
7
+
8
+ import { resolveSession } from '../access'
9
+
10
+ export interface ApiContextDeps<
11
+ TSchema extends Record<string, unknown> = Record<string, unknown>,
12
+ TEnv = Record<string, unknown>,
13
+ > {
14
+ db: DbFor<TSchema>
15
+ env: TEnv
16
+ storage: StorageFacade
17
+ email: EmailFacade
18
+ jobs: JobsRuntimeFacade
19
+ realtime: RealtimeFacade<TSchema>
20
+ auth: AuthInstance
21
+ authResolver?: AuthSessionResolver
22
+ }
23
+
24
+ export interface ApiContext<
25
+ TSchema extends Record<string, unknown> = Record<string, unknown>,
26
+ TEnv = Record<string, unknown>,
27
+ > {
28
+ db: DbFor<TSchema>
29
+ env: TEnv
30
+ storage: StorageFacade
31
+ email: EmailFacade
32
+ jobs: JobsRuntimeFacade
33
+ realtime: RealtimeFacade<TSchema>
34
+ auth: AuthInstance
35
+ request: Request
36
+ resHeaders: Headers
37
+ getRawBody: () => Promise<string>
38
+ getSession: () => Promise<{
39
+ user: AccessUser | null
40
+ activeOrganizationId: string | null
41
+ }>
42
+ }
43
+
44
+ export function createApiContext<
45
+ TSchema extends Record<string, unknown> = Record<string, unknown>,
46
+ TEnv = Record<string, unknown>,
47
+ >(
48
+ deps: ApiContextDeps<TSchema, TEnv>,
49
+ request: Request,
50
+ ): ApiContext<TSchema, TEnv> {
51
+ // Reserve the body stream before a transport codec consumes `request`.
52
+ const rawBodyRequest = request.clone()
53
+ let rawBodyPromise: Promise<string> | undefined
54
+ let sessionPromise:
55
+ | Promise<{ user: AccessUser | null; activeOrganizationId: string | null }>
56
+ | undefined
57
+
58
+ const getSession = () => {
59
+ if (!sessionPromise) {
60
+ sessionPromise = resolveSession(deps.authResolver, request.headers)
61
+ }
62
+ return sessionPromise
63
+ }
64
+
65
+ const getRawBody = () => {
66
+ if (!rawBodyPromise) rawBodyPromise = rawBodyRequest.text()
67
+ return rawBodyPromise
68
+ }
69
+
70
+ return {
71
+ db: deps.db,
72
+ env: deps.env,
73
+ storage: deps.storage,
74
+ email: deps.email,
75
+ jobs: deps.jobs,
76
+ realtime: deps.realtime,
77
+ auth: deps.auth,
78
+ request,
79
+ resHeaders: new Headers(),
80
+ getRawBody,
81
+ getSession,
82
+ }
83
+ }