bunderstack 0.22.1 → 0.23.1
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/CHANGELOG.md +37 -0
- package/dist/api/catalog.d.ts +8 -0
- package/dist/api/catalog.d.ts.map +1 -0
- package/dist/api/catalog.js +56 -0
- package/dist/api/catalog.js.map +1 -0
- package/dist/api/registry.d.ts.map +1 -1
- package/dist/api/registry.js +1 -0
- package/dist/api/registry.js.map +1 -1
- package/dist/api/router.d.ts +3 -0
- package/dist/api/router.d.ts.map +1 -1
- package/dist/api/router.js +7 -2
- package/dist/api/router.js.map +1 -1
- package/dist/backend.d.ts.map +1 -1
- package/dist/backend.js +9 -1
- package/dist/backend.js.map +1 -1
- package/dist/blueprint.d.ts +21 -1
- package/dist/blueprint.d.ts.map +1 -1
- package/dist/blueprint.js +45 -17
- package/dist/blueprint.js.map +1 -1
- package/dist/env.d.ts +18 -0
- package/dist/env.d.ts.map +1 -1
- package/dist/env.js +1 -0
- package/dist/env.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/jobs/index.d.ts +1 -1
- package/dist/jobs/index.d.ts.map +1 -1
- package/dist/jobs/index.js.map +1 -1
- package/dist/jobs/runtime.d.ts +5 -1
- package/dist/jobs/runtime.d.ts.map +1 -1
- package/dist/jobs/runtime.js +15 -5
- package/dist/jobs/runtime.js.map +1 -1
- package/dist/jobs/worker.d.ts +6 -0
- package/dist/jobs/worker.d.ts.map +1 -1
- package/dist/jobs/worker.js +77 -21
- package/dist/jobs/worker.js.map +1 -1
- package/dist/manifest.d.ts +20 -0
- package/dist/manifest.d.ts.map +1 -1
- package/dist/manifest.js +68 -6
- package/dist/manifest.js.map +1 -1
- package/dist/readiness-probes.d.ts +4 -0
- package/dist/readiness-probes.d.ts.map +1 -0
- package/dist/readiness-probes.js +23 -0
- package/dist/readiness-probes.js.map +1 -0
- package/dist/readiness.d.ts +39 -0
- package/dist/readiness.d.ts.map +1 -0
- package/dist/readiness.js +72 -0
- package/dist/readiness.js.map +1 -0
- package/dist/runtime.d.ts +1 -1
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +15 -5
- package/dist/runtime.js.map +1 -1
- package/llms-full.txt +2979 -0
- package/package.json +2 -1
package/llms-full.txt
ADDED
|
@@ -0,0 +1,2979 @@
|
|
|
1
|
+
Bunderstack full documentation
|
|
2
|
+
|
|
3
|
+
Generated from the canonical website documentation. The committed bunderstack.blueprint.yaml remains authoritative for an individual application.
|
|
4
|
+
|
|
5
|
+
API PROCEDURES
|
|
6
|
+
|
|
7
|
+
Generated CRUD, files, realtime, and your own behavior live in one oRPC graph.
|
|
8
|
+
Declare the builder once at module scope, then write router modules that import
|
|
9
|
+
the bases they need.
|
|
10
|
+
|
|
11
|
+
## Declare the builder
|
|
12
|
+
|
|
13
|
+
`defineApi` takes the values you already have and infers the types from them.
|
|
14
|
+
It reads nothing at runtime, so a module can call it at import time.
|
|
15
|
+
|
|
16
|
+
```ts
|
|
17
|
+
// src/api/base.ts
|
|
18
|
+
import { defineApi } from 'bunderstack'
|
|
19
|
+
|
|
20
|
+
import { envSchema } from './env'
|
|
21
|
+
import { schema } from './schema'
|
|
22
|
+
|
|
23
|
+
export const o = defineApi({ schema, env: envSchema })
|
|
24
|
+
|
|
25
|
+
export const publicProcedure = o.public
|
|
26
|
+
export const protectedProcedure = o.protected
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
`context.db` is typed from `schema`, and `context.env` from `envSchema`. You do
|
|
30
|
+
not write the generic parameters yourself.
|
|
31
|
+
|
|
32
|
+
## Write router modules
|
|
33
|
+
|
|
34
|
+
A router is a plain object. Import the base, export the object:
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
// src/api/boards.ts
|
|
38
|
+
import * as v from 'valibot'
|
|
39
|
+
|
|
40
|
+
import { protectedProcedure } from './base'
|
|
41
|
+
|
|
42
|
+
export const boardsRouter = {
|
|
43
|
+
stats: protectedProcedure
|
|
44
|
+
.route({ method: 'GET', path: '/api/board-stats', tags: ['boards'] })
|
|
45
|
+
.input(v.object({ boardId: v.string() }))
|
|
46
|
+
.handler(async ({ context, input }) => {
|
|
47
|
+
const rows = await loadBoardTodos(context.db, input.boardId)
|
|
48
|
+
return {
|
|
49
|
+
total: rows.length,
|
|
50
|
+
done: rows.filter((row) => row.done).length,
|
|
51
|
+
}
|
|
52
|
+
}),
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Collect the modules and pass the object to `bunderstack`:
|
|
57
|
+
|
|
58
|
+
```ts
|
|
59
|
+
// src/api/index.ts
|
|
60
|
+
import { boardsRouter } from './boards'
|
|
61
|
+
import { projectsRouter } from './projects'
|
|
62
|
+
|
|
63
|
+
export const api = { boards: boardsRouter, projects: projectsRouter }
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
const backend = bunderstack({ schema, database, api })
|
|
68
|
+
const app = await backend.start()
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
The client receives `api.boards.stats.call()`,
|
|
72
|
+
`api.boards.stats.queryOptions()`, and the inferred result type. The `.route()`
|
|
73
|
+
call also exposes the same procedure as ordinary HTTP.
|
|
74
|
+
|
|
75
|
+
`api` also accepts a callback — `api: (o) => ({ … })` — for a router that must
|
|
76
|
+
be built from the framework builder at configuration time. For everything else
|
|
77
|
+
the object form keeps router modules free of factory wrappers.
|
|
78
|
+
|
|
79
|
+
## Procedure bases
|
|
80
|
+
|
|
81
|
+
| Base | Session behavior | Use it for |
|
|
82
|
+
| ------------- | ----------------------------------------------------------- | ------------------------------------ |
|
|
83
|
+
| `o.public` | Session is resolved only if you call `context.getSession()` | Public application behavior |
|
|
84
|
+
| `o.protected` | Resolves the session and narrows `context.user` | User-owned or private behavior |
|
|
85
|
+
| `o.webhook` | Public and preserves access to the exact raw body | Provider callbacks and signed events |
|
|
86
|
+
|
|
87
|
+
Every handler context contains typed `db` and `env`, plus `storage`, `email`,
|
|
88
|
+
`jobs`, `realtime`, `auth`, `request`, `resHeaders`, `getSession()`,
|
|
89
|
+
`peekSession()`, and `getRawBody()`.
|
|
90
|
+
|
|
91
|
+
## Extend a base
|
|
92
|
+
|
|
93
|
+
A base is an oRPC builder, so `.use()` gives you your own. Declare it next to
|
|
94
|
+
the others and import it like any other base:
|
|
95
|
+
|
|
96
|
+
```ts
|
|
97
|
+
// src/api/base.ts
|
|
98
|
+
export const adminProcedure = o.protected.use(
|
|
99
|
+
async ({ context, next, errors }) => {
|
|
100
|
+
if (context.user.role !== 'admin') {
|
|
101
|
+
throw errors.FORBIDDEN({ message: 'Admin access required' })
|
|
102
|
+
}
|
|
103
|
+
return next()
|
|
104
|
+
},
|
|
105
|
+
)
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
A middleware can also add to the context. Whatever you pass to `next` is
|
|
109
|
+
merged, and later handlers see it typed:
|
|
110
|
+
|
|
111
|
+
```ts
|
|
112
|
+
export const orgProcedure = o.protected.use(
|
|
113
|
+
async ({ context, next, errors }) => {
|
|
114
|
+
const organizationId = context.session.activeOrganizationId
|
|
115
|
+
if (!organizationId) {
|
|
116
|
+
throw errors.FORBIDDEN({ message: 'No active organization' })
|
|
117
|
+
}
|
|
118
|
+
return next({ context: { organizationId } })
|
|
119
|
+
},
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
// context.organizationId is a string here, with no extra annotation.
|
|
123
|
+
export const membersRouter = {
|
|
124
|
+
list: orgProcedure.handler(({ context }) =>
|
|
125
|
+
listMembers(context.db, context.organizationId),
|
|
126
|
+
),
|
|
127
|
+
}
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
To apply a middleware to **every** procedure, including generated CRUD, storage
|
|
131
|
+
and realtime, register it in the config instead. See
|
|
132
|
+
[Middleware](/docs/middleware).
|
|
133
|
+
|
|
134
|
+
## Input and output schemas
|
|
135
|
+
|
|
136
|
+
`.input(schema)` accepts any Standard Schema implementation. Bunderstack
|
|
137
|
+
examples use Valibot, but the framework does not require it from applications.
|
|
138
|
+
|
|
139
|
+
The handler's return value is the default output type. You do **not** need to
|
|
140
|
+
write an output validator for every procedure.
|
|
141
|
+
|
|
142
|
+
Use `.output(schema)` when runtime output validation has a concrete purpose:
|
|
143
|
+
|
|
144
|
+
- the value crosses a third-party trust boundary;
|
|
145
|
+
- output transformation is required;
|
|
146
|
+
- a precise response schema is important in generated OpenAPI.
|
|
147
|
+
|
|
148
|
+
```ts
|
|
149
|
+
const result = v.object({ id: v.string(), title: v.string() })
|
|
150
|
+
|
|
151
|
+
createPost: protectedProcedure
|
|
152
|
+
.input(v.object({ title: v.pipe(v.string(), v.minLength(1)) }))
|
|
153
|
+
.output(result)
|
|
154
|
+
.handler(async ({ context, input }) => createPost(context, input))
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
This is output validation by choice, not ceremony.
|
|
158
|
+
|
|
159
|
+
## Typed errors
|
|
160
|
+
|
|
161
|
+
Every procedure carries one declared error map. Raise from it with the `errors`
|
|
162
|
+
argument, which handlers and middleware both receive:
|
|
163
|
+
|
|
164
|
+
```ts
|
|
165
|
+
.handler(async ({ context, input, errors }) => {
|
|
166
|
+
const board = await findBoard(context.db, input.id)
|
|
167
|
+
if (!board) throw errors.NOT_FOUND({ message: 'Board not found' })
|
|
168
|
+
return board
|
|
169
|
+
})
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
The codes are `BAD_REQUEST`, `UNAUTHORIZED`, `FORBIDDEN`, `NOT_FOUND`,
|
|
173
|
+
`CONFLICT`, `PAYLOAD_TOO_LARGE`, and `TOO_MANY_REQUESTS`. Each maps to its
|
|
174
|
+
standard HTTP status, and the client can narrow on them with the oRPC
|
|
175
|
+
`isDefinedError` helpers. Extra context goes in `data.details`:
|
|
176
|
+
|
|
177
|
+
```ts
|
|
178
|
+
throw errors.CONFLICT({
|
|
179
|
+
message: 'Generation already running',
|
|
180
|
+
data: { details: { adaptationId } },
|
|
181
|
+
})
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
Do not construct `ORPCError` by hand. Code outside a handler — a service
|
|
185
|
+
function or a job — has no `errors` argument, so throw `BunderstackError`
|
|
186
|
+
there; the framework maps it to the same typed error:
|
|
187
|
+
|
|
188
|
+
```ts
|
|
189
|
+
import { BunderstackError } from 'bunderstack'
|
|
190
|
+
|
|
191
|
+
export async function spendCredits(db: Db, userId: string, amount: number) {
|
|
192
|
+
const balance = await readBalance(db, userId)
|
|
193
|
+
if (balance < amount) {
|
|
194
|
+
throw new BunderstackError('FORBIDDEN', 'Insufficient credits')
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
Errors thrown by framework facilities use the same contract, so clients do not
|
|
200
|
+
need a second error model for generated CRUD.
|
|
201
|
+
|
|
202
|
+
## List endpoints outside CRUD
|
|
203
|
+
|
|
204
|
+
Generated CRUD lists already support filters, sorting, cursors, and counts.
|
|
205
|
+
`listSpec` gives the same contract to a procedure you write yourself — an admin
|
|
206
|
+
view over a table that is not exposed as CRUD, for example:
|
|
207
|
+
|
|
208
|
+
```ts
|
|
209
|
+
import { listSpec } from 'bunderstack'
|
|
210
|
+
|
|
211
|
+
const logsList = listSpec(appLogs, {
|
|
212
|
+
filterable: ['level', 'action', 'userId'],
|
|
213
|
+
sortable: ['createdAt'],
|
|
214
|
+
defaultSort: { column: 'createdAt', order: 'desc' },
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
export const adminRouter = {
|
|
218
|
+
logs: adminProcedure.input(logsList.input).handler(logsList.handler),
|
|
219
|
+
}
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
The response is a `ListResult`:
|
|
223
|
+
`{ items, hasMore, nextCursor, total, limit, offset, sort, order }`. Pass
|
|
224
|
+
`count: true` in the input to receive `total`.
|
|
225
|
+
|
|
226
|
+
`listSpec` returns the schema and the handler separately rather than a finished
|
|
227
|
+
procedure. That keeps your base procedure concrete at the call site, which is
|
|
228
|
+
what preserves the row type all the way to the client. It reads no `access`
|
|
229
|
+
configuration: the base procedure carries the policy.
|
|
230
|
+
|
|
231
|
+
## Typing helpers that take the database
|
|
232
|
+
|
|
233
|
+
A service function in its own module cannot reach `typeof app.db` without an
|
|
234
|
+
import cycle. Use the exported types instead:
|
|
235
|
+
|
|
236
|
+
```ts
|
|
237
|
+
import type { BunderstackDb, BunderstackTx } from 'bunderstack'
|
|
238
|
+
|
|
239
|
+
import type { schema } from './schema'
|
|
240
|
+
|
|
241
|
+
type Db = BunderstackDb<typeof schema>
|
|
242
|
+
type Tx = BunderstackTx<typeof schema>
|
|
243
|
+
|
|
244
|
+
export async function transfer(db: Db, from: string, to: string) {
|
|
245
|
+
await db.transaction(async (tx: Tx) => {
|
|
246
|
+
/* … */
|
|
247
|
+
})
|
|
248
|
+
}
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
## Organizing larger APIs
|
|
252
|
+
|
|
253
|
+
Group by domain. Nesting is ordinary oRPC router shape:
|
|
254
|
+
|
|
255
|
+
```ts
|
|
256
|
+
export const api = {
|
|
257
|
+
projects: projectsRouter,
|
|
258
|
+
billing: { invoices: invoicesRouter, plans: plansRouter },
|
|
259
|
+
}
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
Procedure names may not collide with generated tables or reserved namespaces
|
|
263
|
+
such as `files`, `realtime`, and `health`; collisions fail at startup.
|
|
264
|
+
|
|
265
|
+
## OpenAPI
|
|
266
|
+
|
|
267
|
+
Set `openapi: true` to serve `/api/openapi.json`. Route metadata and Standard
|
|
268
|
+
Schema inputs are projected into the document. RPC remains the source of type
|
|
269
|
+
safety; OpenAPI is useful for mobile code generation, external consumers, and
|
|
270
|
+
API inspection without becoming a second implementation.
|
|
271
|
+
|
|
272
|
+
See [HTTP & Webhooks](/docs/http-webhooks) for detailed HTTP inputs, signature
|
|
273
|
+
verification, and raw responses.
|
|
274
|
+
|
|
275
|
+
API REFERENCE
|
|
276
|
+
|
|
277
|
+
This page summarizes the stable public concepts. TypeScript remains the exact
|
|
278
|
+
reference for generic details and adapter-specific types.
|
|
279
|
+
|
|
280
|
+
## `bunderstack(options)`
|
|
281
|
+
|
|
282
|
+
```ts
|
|
283
|
+
function bunderstack<
|
|
284
|
+
TSchema,
|
|
285
|
+
TAccess,
|
|
286
|
+
TStorage,
|
|
287
|
+
TEnv,
|
|
288
|
+
TJobs,
|
|
289
|
+
TApi,
|
|
290
|
+
>(options: BunderstackConfig<...>): BunderstackBackend<BunderstackApp<...>>
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
Required options:
|
|
294
|
+
|
|
295
|
+
- `schema`: a record containing the application's Drizzle tables;
|
|
296
|
+
- `database.adapter`: one statically imported database adapter.
|
|
297
|
+
|
|
298
|
+
Major optional groups:
|
|
299
|
+
|
|
300
|
+
- `access`: generated CRUD exposure, ownership, filters, sorting, and guards;
|
|
301
|
+
- `auth` / `authResolver`: Better Auth and custom session resolution;
|
|
302
|
+
- `storage`, `email`, `env`, and `jobs`: application facilities;
|
|
303
|
+
- `api`: your oRPC router, as an object or as `(o) => router`;
|
|
304
|
+
- `middleware`: oRPC middleware applied to every procedure in the graph;
|
|
305
|
+
- `realtime`: memory or Redis Publisher configuration;
|
|
306
|
+
- `rateLimit`, `idempotency`, `background`, and `openapi`.
|
|
307
|
+
|
|
308
|
+
See [Configuration](/docs/configuration) for examples and defaults.
|
|
309
|
+
|
|
310
|
+
`bunderstack()` only declares the backend. `backend.manifest` is available
|
|
311
|
+
synchronously for Blueprint generation. Call `await backend.start({ env })` to
|
|
312
|
+
materialize a production runtime, or `await backend.test()` to create an
|
|
313
|
+
isolated test fixture owned by the current lexical scope.
|
|
314
|
+
|
|
315
|
+
## `BunderstackApp`
|
|
316
|
+
|
|
317
|
+
```ts
|
|
318
|
+
type BunderstackApp = {
|
|
319
|
+
handler(request: Request): Promise<Response>
|
|
320
|
+
db: DbFor<TSchema>
|
|
321
|
+
auth: AuthInstance
|
|
322
|
+
storage: StorageFacade
|
|
323
|
+
email: EmailFacade
|
|
324
|
+
env: ValidatedEnv<TEnv>
|
|
325
|
+
jobs: JobsFacade<TJobs>
|
|
326
|
+
realtime: RealtimeFacade<TSchema>
|
|
327
|
+
startWorker(options?): Promise<WorkerHandle>
|
|
328
|
+
runWorker(options?): Promise<void>
|
|
329
|
+
close(): Promise<void>
|
|
330
|
+
readonly status: LifecycleStatus
|
|
331
|
+
readonly signal: AbortSignal
|
|
332
|
+
readonly backgroundRunning: boolean
|
|
333
|
+
readonly $inferClient?: ClientTypeCarrier
|
|
334
|
+
}
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
`$inferClient` is type-only and does not exist at runtime. Export
|
|
338
|
+
`type App = typeof app` for client inference.
|
|
339
|
+
|
|
340
|
+
## API builder
|
|
341
|
+
|
|
342
|
+
```ts
|
|
343
|
+
const o = defineApi({ schema, env: envSchema })
|
|
344
|
+
|
|
345
|
+
o.public // no session resolution
|
|
346
|
+
o.protected // resolves the session, narrows context.user
|
|
347
|
+
o.webhook // public, preserves the exact raw body
|
|
348
|
+
o.middleware(fn) // a standalone middleware over ApiContext
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
`defineApi` infers `TSchema` and `TEnv` from the values it receives, so an
|
|
352
|
+
application never writes `BunderstackApiBuilder<…>` by hand. It reads nothing
|
|
353
|
+
at runtime and can be called at module scope. `createApiBuilder<TSchema,
|
|
354
|
+
TEnv>()` remains available when you want to pass the generics explicitly.
|
|
355
|
+
|
|
356
|
+
The three bases are oRPC procedure builders with the shared Bunderstack error
|
|
357
|
+
contract and `ApiContext`:
|
|
358
|
+
|
|
359
|
+
```ts
|
|
360
|
+
type ApiContext = {
|
|
361
|
+
db: DbFor<TSchema>
|
|
362
|
+
env: ValidatedEnv<TEnv>
|
|
363
|
+
storage: StorageFacade
|
|
364
|
+
email: EmailFacade
|
|
365
|
+
jobs: JobsRuntimeFacade
|
|
366
|
+
realtime: RealtimeFacade<TSchema>
|
|
367
|
+
auth: AuthInstance
|
|
368
|
+
request: Request
|
|
369
|
+
resHeaders: Headers
|
|
370
|
+
getRawBody(): Promise<string>
|
|
371
|
+
getSession(): Promise<{
|
|
372
|
+
user: AccessUser | null
|
|
373
|
+
activeOrganizationId: string | null
|
|
374
|
+
}>
|
|
375
|
+
/** The already-resolved session, or undefined. Never starts a resolution. */
|
|
376
|
+
peekSession():
|
|
377
|
+
| { user: AccessUser | null; activeOrganizationId: string | null }
|
|
378
|
+
| undefined
|
|
379
|
+
}
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
`o.protected` adds non-null `context.user` and the active organization session
|
|
383
|
+
to handlers. Inputs and optional outputs accept Standard Schema.
|
|
384
|
+
|
|
385
|
+
`peekSession()` exists for graph-wide middleware, which runs before
|
|
386
|
+
authentication. Use it for observability only, never for authorization — see
|
|
387
|
+
[Middleware](/docs/middleware#reading-the-caller).
|
|
388
|
+
|
|
389
|
+
## Helper exports
|
|
390
|
+
|
|
391
|
+
| Export | Purpose |
|
|
392
|
+
| ---------------------------- | --------------------------------------------------------- |
|
|
393
|
+
| `defineApi({ schema, env })` | The procedure builder, with generics inferred from values |
|
|
394
|
+
| `listSpec(table, options)` | Input schema and handler for a list endpoint outside CRUD |
|
|
395
|
+
| `BunderstackError` | Typed error for code outside a handler |
|
|
396
|
+
| `BunderstackDb<TSchema>` | The database type for a helper parameter |
|
|
397
|
+
| `BunderstackTx<TSchema>` | The transaction handle inside `db.transaction` |
|
|
398
|
+
|
|
399
|
+
## Database and provisioning
|
|
400
|
+
|
|
401
|
+
Import one of `libsql()`, `pglite()`, `bunSql()`, or `postgresJs()` from its
|
|
402
|
+
`bunderstack/*` subpath. The schema dialect and adapter dialect must
|
|
403
|
+
match.
|
|
404
|
+
|
|
405
|
+
```ts
|
|
406
|
+
import { provision } from 'bunderstack/provision'
|
|
407
|
+
|
|
408
|
+
await provision(app, { force: false })
|
|
409
|
+
```
|
|
410
|
+
|
|
411
|
+
Without a migration journal, provisioning uses the development schema push.
|
|
412
|
+
With committed migrations, it applies pending migrations.
|
|
413
|
+
|
|
414
|
+
## `bunderstack/client`
|
|
415
|
+
|
|
416
|
+
```ts
|
|
417
|
+
import { createClient, createLiveView } from 'bunderstack/client'
|
|
418
|
+
import { createRestClient } from 'bunderstack/client-rest'
|
|
419
|
+
import { useLiveView as useReactLiveView } from 'bunderstack/client-react'
|
|
420
|
+
import { createLiveStore } from 'bunderstack/client-solid'
|
|
421
|
+
import { liveStore } from 'bunderstack/client-svelte'
|
|
422
|
+
import { useLiveView as useVueLiveView } from 'bunderstack/client-vue'
|
|
423
|
+
```
|
|
424
|
+
|
|
425
|
+
A zero-dependency typed RPC client and confirmed realtime `LiveView` with reactive store adapters for Solid, React, Svelte, and Vue. Use it for lightweight clients, mobile/native applications, or when you don't need TanStack Query.
|
|
426
|
+
|
|
427
|
+
## `bunderstack/query`
|
|
428
|
+
|
|
429
|
+
```ts
|
|
430
|
+
function createClient<TApp>(options?: {
|
|
431
|
+
baseUrl?: string
|
|
432
|
+
fetch?: TransportFetch
|
|
433
|
+
queryClient?: QueryClient
|
|
434
|
+
}): BunderstackClient<TApp>
|
|
435
|
+
```
|
|
436
|
+
|
|
437
|
+
The result contains the complete oRPC router utilities and file helpers:
|
|
438
|
+
|
|
439
|
+
```ts
|
|
440
|
+
api.posts.list.call(input)
|
|
441
|
+
api.posts.list.queryOptions({ input })
|
|
442
|
+
api.posts.create.mutationOptions(options)
|
|
443
|
+
api.customProcedure.call(input)
|
|
444
|
+
api.files.images.upload(file)
|
|
445
|
+
api.files.images.url(id, transforms)
|
|
446
|
+
```
|
|
447
|
+
|
|
448
|
+
Other exports include `createApiClient`, `syncRealtime`, `InferSchema`,
|
|
449
|
+
`InferSelect`, `InferInsert`, and the list input helpers.
|
|
450
|
+
|
|
451
|
+
## `bunderstack/sync`
|
|
452
|
+
|
|
453
|
+
```ts
|
|
454
|
+
function createSyncClient<TApp>(options: {
|
|
455
|
+
queryClient: QueryClient
|
|
456
|
+
baseUrl?: string
|
|
457
|
+
fetch?: TransportFetch
|
|
458
|
+
realtime?: boolean
|
|
459
|
+
}): BunderstackSyncClient<TApp>
|
|
460
|
+
```
|
|
461
|
+
|
|
462
|
+
Each generated table exposes `collection`, `table`,
|
|
463
|
+
`scopedCollection(options)`, and `collectionByIds(ids)`. The client starts its
|
|
464
|
+
typed realtime iterator in the browser by default and keeps it disabled during
|
|
465
|
+
SSR.
|
|
466
|
+
|
|
467
|
+
## `bunderstack/start`
|
|
468
|
+
|
|
469
|
+
```ts
|
|
470
|
+
createApiHandlers(app)
|
|
471
|
+
createIsomorphicFetch(options?)
|
|
472
|
+
getSessionUser(app, request)
|
|
473
|
+
createStartAuthClient(options?)
|
|
474
|
+
bunderstackStart<TApp>(options?)
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
These adapters mount `app.handler`, resolve relative API URLs during SSR, and
|
|
478
|
+
create app-inferred clients without changing the server graph.
|
|
479
|
+
|
|
480
|
+
## `RealtimeFacade`
|
|
481
|
+
|
|
482
|
+
```ts
|
|
483
|
+
interface RealtimeFacade<TSchema> {
|
|
484
|
+
readonly enabled: boolean
|
|
485
|
+
readonly transport: 'disabled' | 'memory' | 'redis'
|
|
486
|
+
publish<TTable extends SchemaTable<TSchema>>(
|
|
487
|
+
table: TTable,
|
|
488
|
+
action: 'create' | 'update' | 'delete',
|
|
489
|
+
record: InferSelectModel<TTable>,
|
|
490
|
+
): Promise<void>
|
|
491
|
+
}
|
|
492
|
+
```
|
|
493
|
+
|
|
494
|
+
Generated writes publish automatically. Call `publish()` only for custom
|
|
495
|
+
writes performed through `context.db` or background jobs.
|
|
496
|
+
|
|
497
|
+
## Storage
|
|
498
|
+
|
|
499
|
+
`StorageFacade` provides server-side `upload`, `getUrl`, and bucket-aware
|
|
500
|
+
operations. Browser clients receive `upload`, `url`, and `delete` helpers per
|
|
501
|
+
declared bucket. Upload and image-transform rules are documented in
|
|
502
|
+
[Storage](/docs/storage) and [Thumbnails](/docs/thumbnails).
|
|
503
|
+
|
|
504
|
+
## Email
|
|
505
|
+
|
|
506
|
+
```ts
|
|
507
|
+
interface EmailFacade {
|
|
508
|
+
send(message: EmailMessage): Promise<SentEmail>
|
|
509
|
+
}
|
|
510
|
+
```
|
|
511
|
+
|
|
512
|
+
`EmailMessage` supports `to`, `subject`, HTML or text bodies, sender override,
|
|
513
|
+
reply-to, CC, and BCC. Configure the console, Resend, SMTP, or a custom adapter.
|
|
514
|
+
|
|
515
|
+
## Testing (`bunderstack/testing`)
|
|
516
|
+
|
|
517
|
+
`backend.test(options)` produces an isolated test fixture with lexical async disposal
|
|
518
|
+
(`await using`). For a suite, `backend.test.configure()` removes repeated environment,
|
|
519
|
+
database, and seed boilerplate:
|
|
520
|
+
|
|
521
|
+
```ts
|
|
522
|
+
import { backend } from './bunderstack'
|
|
523
|
+
|
|
524
|
+
const createFixture = backend.test.configure({
|
|
525
|
+
database: { mode: 'temporary', schema: 'migrations' },
|
|
526
|
+
setup: async (fixture) => {
|
|
527
|
+
const identity = fixture.auth.mockSession({
|
|
528
|
+
id: 'alice',
|
|
529
|
+
email: 'alice@example.com',
|
|
530
|
+
name: 'Alice',
|
|
531
|
+
})
|
|
532
|
+
return { identity, client: fixture.client(identity) }
|
|
533
|
+
},
|
|
534
|
+
})
|
|
535
|
+
|
|
536
|
+
test('posts procedure works', async () => {
|
|
537
|
+
await using fixture = await createFixture()
|
|
538
|
+
const { client } = fixture.context
|
|
539
|
+
|
|
540
|
+
const result = await client.posts.create({ title: 'First post' })
|
|
541
|
+
expect(result.title).toBe('First post')
|
|
542
|
+
|
|
543
|
+
await fixture.jobs.runUntilIdle()
|
|
544
|
+
expect(fixture.email.sent).toHaveLength(1)
|
|
545
|
+
})
|
|
546
|
+
```
|
|
547
|
+
|
|
548
|
+
`TestFixture` provides:
|
|
549
|
+
|
|
550
|
+
- `fixture.app`: the materialized application runtime;
|
|
551
|
+
- `fixture.context`: the typed value returned by configured `setup`;
|
|
552
|
+
- `fixture.defer(cleanup)`: LIFO async cleanup before application shutdown;
|
|
553
|
+
- `fixture.auth`: real `signUpEmail()`, `signInEmail()`, `getSession()`, `signOut()`,
|
|
554
|
+
and `verifyEmail()` flows, plus header-scoped `mockSession()` identities;
|
|
555
|
+
- `fixture.client(identity?)`: inferred typed oRPC client calling `app.handler` directly in-process;
|
|
556
|
+
- `fixture.jobs`: deterministic `runNext()` / `runUntilIdle()` execution and
|
|
557
|
+
`inspect()` / `pending()` / `failed()` queue assertions;
|
|
558
|
+
- `fixture.logs`: captured internal `entries`, `errors`, and `warnings` with `clear()`;
|
|
559
|
+
- `fixture.email`: in-memory capture of sent emails at `fixture.email.sent`;
|
|
560
|
+
- `fixture.storage`: isolated test storage with `fixture.storage.read(key)`.
|
|
561
|
+
|
|
562
|
+
`configure()` defaults and per-test options deep-merge `env` and `database`. Runtime
|
|
563
|
+
logs are captured by default; configure `logs: 'inherit'` to capture and forward to
|
|
564
|
+
the console, or `logs: 'silent'` to discard them.
|
|
565
|
+
|
|
566
|
+
AUTH
|
|
567
|
+
|
|
568
|
+
Bunderstack uses [BetterAuth](https://www.better-auth.com) under the hood. Auth routes are mounted at `/api/auth/*`.
|
|
569
|
+
|
|
570
|
+
## Email/password
|
|
571
|
+
|
|
572
|
+
```ts
|
|
573
|
+
bunderstack({
|
|
574
|
+
schema,
|
|
575
|
+
auth: {
|
|
576
|
+
emailAndPassword: { enabled: true },
|
|
577
|
+
secret: process.env.AUTH_SECRET,
|
|
578
|
+
},
|
|
579
|
+
})
|
|
580
|
+
```
|
|
581
|
+
|
|
582
|
+
```bash
|
|
583
|
+
curl -X POST /api/auth/sign-up/email \
|
|
584
|
+
-H 'Content-Type: application/json' \
|
|
585
|
+
-d '{"email":"user@example.com","password":"pass123","name":"Alice"}'
|
|
586
|
+
|
|
587
|
+
curl -X POST /api/auth/sign-in/email \
|
|
588
|
+
-H 'Content-Type: application/json' \
|
|
589
|
+
-d '{"email":"user@example.com","password":"pass123"}'
|
|
590
|
+
```
|
|
591
|
+
|
|
592
|
+
## OAuth
|
|
593
|
+
|
|
594
|
+
```ts
|
|
595
|
+
bunderstack({
|
|
596
|
+
schema,
|
|
597
|
+
auth: {
|
|
598
|
+
socialProviders: {
|
|
599
|
+
github: {
|
|
600
|
+
clientId: process.env.GITHUB_CLIENT_ID!,
|
|
601
|
+
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
|
|
602
|
+
},
|
|
603
|
+
google: {
|
|
604
|
+
clientId: process.env.GOOGLE_CLIENT_ID!,
|
|
605
|
+
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
|
|
606
|
+
},
|
|
607
|
+
},
|
|
608
|
+
},
|
|
609
|
+
})
|
|
610
|
+
```
|
|
611
|
+
|
|
612
|
+
## Database hooks
|
|
613
|
+
|
|
614
|
+
BetterAuth hooks often need to write — seed a balance on sign-up, log the event.
|
|
615
|
+
Pass `auth` as a builder to get the app's own database instead of opening a
|
|
616
|
+
second connection:
|
|
617
|
+
|
|
618
|
+
```ts
|
|
619
|
+
bunderstack({
|
|
620
|
+
schema,
|
|
621
|
+
auth: ({ db, env }) => ({
|
|
622
|
+
emailAndPassword: { enabled: true },
|
|
623
|
+
databaseHooks: {
|
|
624
|
+
user: {
|
|
625
|
+
create: {
|
|
626
|
+
after: async (user) => {
|
|
627
|
+
await db
|
|
628
|
+
.insert(schema.credits)
|
|
629
|
+
.values({ userId: user.id, amount: 0 })
|
|
630
|
+
},
|
|
631
|
+
},
|
|
632
|
+
},
|
|
633
|
+
},
|
|
634
|
+
}),
|
|
635
|
+
})
|
|
636
|
+
```
|
|
637
|
+
|
|
638
|
+
`db` is typed from `schema` alone, so the builder can live in its own file — it
|
|
639
|
+
never imports the app whose type it helps produce. Same reason `api`, `jobs`,
|
|
640
|
+
and `routes` take builders. The plain object form keeps working when no hook
|
|
641
|
+
needs the database.
|
|
642
|
+
|
|
643
|
+
## Reading the session in server functions
|
|
644
|
+
|
|
645
|
+
```ts
|
|
646
|
+
// utils/session.ts (TanStack Start)
|
|
647
|
+
import { getRequest } from '@tanstack/react-start/server'
|
|
648
|
+
import { app } from '~/bunderstack'
|
|
649
|
+
|
|
650
|
+
export async function getAuthSession() {
|
|
651
|
+
const request = getRequest()
|
|
652
|
+
if (!request) return null
|
|
653
|
+
return app.auth.api.getSession({ headers: request.headers })
|
|
654
|
+
}
|
|
655
|
+
```
|
|
656
|
+
|
|
657
|
+
You can also access `app.auth` directly — it's just a BetterAuth instance.
|
|
658
|
+
|
|
659
|
+
## Unit testing auth
|
|
660
|
+
|
|
661
|
+
In unit tests, use the lexical testing fixture with the real email auth lifecycle or
|
|
662
|
+
header-scoped mocked identities:
|
|
663
|
+
|
|
664
|
+
```ts
|
|
665
|
+
import { backend } from './bunderstack'
|
|
666
|
+
|
|
667
|
+
test('authenticated routes', async () => {
|
|
668
|
+
await using fixture = await backend.test()
|
|
669
|
+
|
|
670
|
+
// Real sign-up via Better Auth HTTP handlers
|
|
671
|
+
const identity = await fixture.auth.signUpEmail({
|
|
672
|
+
email: 'alice@example.com',
|
|
673
|
+
name: 'Alice',
|
|
674
|
+
})
|
|
675
|
+
await fixture.auth.verifyEmail(identity)
|
|
676
|
+
expect(await fixture.auth.getSession(identity)).not.toBeNull()
|
|
677
|
+
const client = fixture.client(identity)
|
|
678
|
+
|
|
679
|
+
// Multiple mocked users safely coexist in the same fixture.
|
|
680
|
+
const admin = fixture.auth.mockSession(
|
|
681
|
+
{ id: 'admin', email: 'admin@example.com', name: 'Admin' },
|
|
682
|
+
{ activeOrganizationId: 'org_1' },
|
|
683
|
+
)
|
|
684
|
+
const member = fixture.auth.mockSession({
|
|
685
|
+
id: 'member',
|
|
686
|
+
email: 'member@example.com',
|
|
687
|
+
name: 'Member',
|
|
688
|
+
})
|
|
689
|
+
})
|
|
690
|
+
```
|
|
691
|
+
|
|
692
|
+
`signInEmail()` uses the real Better Auth handler and collects its cookies.
|
|
693
|
+
`signOut(identity)` invalidates a real session. `verifyEmail(identity)` follows the
|
|
694
|
+
latest captured verification link for that user's email.
|
|
695
|
+
|
|
696
|
+
## Required schema tables
|
|
697
|
+
|
|
698
|
+
Include `user`, `session`, `account`, `verification` from BetterAuth in your schema.
|
|
699
|
+
Copy full definitions from `examples/standalone/schema.ts`.
|
|
700
|
+
|
|
701
|
+
BACKGROUND JOBS AND CRON
|
|
702
|
+
|
|
703
|
+
Everything in the background is a row in one table. A job is a type, a payload,
|
|
704
|
+
a time to run, and an attempt count. **A cron is a job that gets created on a
|
|
705
|
+
schedule.** One loop — `tick()` — moves rows forward.
|
|
706
|
+
|
|
707
|
+
```ts
|
|
708
|
+
import * as v from 'valibot'
|
|
709
|
+
|
|
710
|
+
const backend = bunderstack({
|
|
711
|
+
schema,
|
|
712
|
+
jobs: (j) =>
|
|
713
|
+
j.define({
|
|
714
|
+
sendEmail: j.job({
|
|
715
|
+
input: v.object({ userId: v.string() }),
|
|
716
|
+
retries: 3,
|
|
717
|
+
handler: async ({ userId }, ctx) => {
|
|
718
|
+
// ctx.db, ctx.email, ctx.storage, ctx.jobs, ctx.realtime, ctx.env
|
|
719
|
+
},
|
|
720
|
+
}),
|
|
721
|
+
dailyReport: j.cron({
|
|
722
|
+
schedule: '0 9 * * 1-5',
|
|
723
|
+
retries: 2,
|
|
724
|
+
handler: async ({ scheduledFor }, ctx) => {},
|
|
725
|
+
}),
|
|
726
|
+
}),
|
|
727
|
+
})
|
|
728
|
+
|
|
729
|
+
const app = await backend.start()
|
|
730
|
+
|
|
731
|
+
await app.jobs.enqueue('sendEmail', { userId: 'usr_123' })
|
|
732
|
+
```
|
|
733
|
+
|
|
734
|
+
`j.job()` is durable queue work and is the only declaration accepted by
|
|
735
|
+
`app.jobs.enqueue()`. Delivery is at-least-once: make handlers idempotent.
|
|
736
|
+
|
|
737
|
+
## Production worker
|
|
738
|
+
|
|
739
|
+
The default `all` role keeps local development and small single-process
|
|
740
|
+
deployments simple. In production, run queue work as a separate process and
|
|
741
|
+
keep the web runtime from auto-starting another loop:
|
|
742
|
+
|
|
743
|
+
```ts
|
|
744
|
+
// src/worker.ts
|
|
745
|
+
import { backend } from './bunderstack/backend'
|
|
746
|
+
|
|
747
|
+
const app = await backend.start({
|
|
748
|
+
env: { ...process.env, BUNDERSTACK_ROLE: 'web' },
|
|
749
|
+
})
|
|
750
|
+
await app.runWorker()
|
|
751
|
+
```
|
|
752
|
+
|
|
753
|
+
Topology is controlled by `BUNDERSTACK_ROLE`, not by application code:
|
|
754
|
+
|
|
755
|
+
| `BUNDERSTACK_ROLE` | Serves HTTP | Runs background work |
|
|
756
|
+
| ------------------ | ----------- | -------------------- |
|
|
757
|
+
| `all` _(default)_ | yes | yes |
|
|
758
|
+
| `web` | yes | no |
|
|
759
|
+
| `worker` | no | yes |
|
|
760
|
+
|
|
761
|
+
Use `BUNDERSTACK_ROLE=web` for the web entry. The dedicated worker command owns
|
|
762
|
+
`runWorker()` and its shutdown lifecycle.
|
|
763
|
+
|
|
764
|
+
```bash
|
|
765
|
+
# One process, everything. The default.
|
|
766
|
+
bun run start
|
|
767
|
+
|
|
768
|
+
# Split production commands.
|
|
769
|
+
BUNDERSTACK_ROLE=web bun run start
|
|
770
|
+
bun run worker
|
|
771
|
+
```
|
|
772
|
+
|
|
773
|
+
`app.backgroundRunning` reports whether the current process runs the loop.
|
|
774
|
+
`app.startWorker()` is useful for an explicitly embedded worker;
|
|
775
|
+
`app.runWorker()` owns a dedicated worker process until shutdown.
|
|
776
|
+
|
|
777
|
+
## Cron
|
|
778
|
+
|
|
779
|
+
`j.cron()` uses a five-field UTC schedule. Each due minute is materialized as a
|
|
780
|
+
job row whose dedupe key is the slot timestamp, so a slot runs exactly once no
|
|
781
|
+
matter how many processes are ticking — including the brief overlap during a
|
|
782
|
+
rolling deploy.
|
|
783
|
+
|
|
784
|
+
Because cron occurrences are jobs, they take the same options queue jobs do:
|
|
785
|
+
|
|
786
|
+
```ts
|
|
787
|
+
weeklyDigest: j.cron({
|
|
788
|
+
schedule: '0 9 * * 1',
|
|
789
|
+
retries: 3, // a throwing handler now retries with backoff
|
|
790
|
+
timeout: 120_000, // lease duration
|
|
791
|
+
catchUp: 'latest', // or 'all'
|
|
792
|
+
onFailed: async (invocation, error, ctx) => {},
|
|
793
|
+
handler: async ({ scheduledFor }, ctx) => {},
|
|
794
|
+
})
|
|
795
|
+
```
|
|
796
|
+
|
|
797
|
+
### Missed slots
|
|
798
|
+
|
|
799
|
+
If the process was down when a slot came due, `catchUp` decides what happens on
|
|
800
|
+
the next tick:
|
|
801
|
+
|
|
802
|
+
- **`'latest'` (default)** — only the most recent missed slot runs. Right for
|
|
803
|
+
handlers that bring state up to date.
|
|
804
|
+
- **`'all'`** — every missed slot runs, bounded by `catchUpWindow` (default one
|
|
805
|
+
hour). Right when each interval represents distinct work.
|
|
806
|
+
|
|
807
|
+
A newly declared cron never backfills from the past — it starts from the minute
|
|
808
|
+
it is first seen.
|
|
809
|
+
|
|
810
|
+
Job names may not begin with `cron:`; the prefix is reserved.
|
|
811
|
+
|
|
812
|
+
## Testing jobs deterministically
|
|
813
|
+
|
|
814
|
+
Fixtures never auto-start background work. `fixture.jobs.runNext()` claims and runs one tick, while `fixture.jobs.runUntilIdle()` runs ticks until all runnable jobs are processed:
|
|
815
|
+
|
|
816
|
+
```ts
|
|
817
|
+
await using fixture = await backend.test()
|
|
818
|
+
|
|
819
|
+
await fixture.app.jobs.enqueue('sendWelcomeEmail', { userId: 'user_1' })
|
|
820
|
+
|
|
821
|
+
const report = await fixture.jobs.runUntilIdle({ failOnJobError: true })
|
|
822
|
+
expect(report.ran).toBe(1)
|
|
823
|
+
|
|
824
|
+
expect(await fixture.jobs.pending({ name: 'sendWelcomeEmail' })).toEqual([])
|
|
825
|
+
expect(await fixture.jobs.failed()).toEqual([])
|
|
826
|
+
```
|
|
827
|
+
|
|
828
|
+
Jobs recursively enqueued by handlers are processed until the queue converges or `maxTicks` is reached. Delayed jobs wait for their explicit `now` timestamp without sleeping.
|
|
829
|
+
|
|
830
|
+
Use `fixture.jobs.inspect(filter?)` for every retained queue row, or
|
|
831
|
+
`pending(filter?)` and `failed(filter?)` for status-specific assertions. Filters
|
|
832
|
+
accept `name` and `dedupeKey`; rows expose `id`, normalized `name`, `kind`, `status`,
|
|
833
|
+
`attempts`, `runAt`, `dedupeKey`, and `lastError`.
|
|
834
|
+
|
|
835
|
+
## Realtime from a separate worker process
|
|
836
|
+
|
|
837
|
+
With `BUNDERSTACK_ROLE=all` — the default — job handlers and realtime subscribers
|
|
838
|
+
share a process, so realtime works with no extra configuration.
|
|
839
|
+
|
|
840
|
+
When you split roles, the web and worker processes are separate. An in-memory
|
|
841
|
+
realtime broker cannot carry an event published by a job handler to clients
|
|
842
|
+
connected to the web process. If a handler calls `ctx.realtime.publish()`,
|
|
843
|
+
configure a shared Redis transport in both:
|
|
844
|
+
|
|
845
|
+
```ts
|
|
846
|
+
const backend = bunderstack({
|
|
847
|
+
schema,
|
|
848
|
+
realtime: { redis: process.env.REDIS_URL! },
|
|
849
|
+
jobs: (j) =>
|
|
850
|
+
j.define({
|
|
851
|
+
generateImage: j.job({
|
|
852
|
+
input: v.object({ itemId: v.string() }),
|
|
853
|
+
handler: async ({ itemId }, ctx) => {
|
|
854
|
+
const item = await generateAndSaveItem(itemId)
|
|
855
|
+
await ctx.realtime.publish(schema.items, 'update', item)
|
|
856
|
+
},
|
|
857
|
+
}),
|
|
858
|
+
}),
|
|
859
|
+
})
|
|
860
|
+
|
|
861
|
+
const app = await backend.start()
|
|
862
|
+
```
|
|
863
|
+
|
|
864
|
+
```bash
|
|
865
|
+
REDIS_URL=redis://localhost:6379 BUNDERSTACK_ROLE=web bun run start
|
|
866
|
+
REDIS_URL=redis://localhost:6379 BUNDERSTACK_ROLE=worker bun run start
|
|
867
|
+
```
|
|
868
|
+
|
|
869
|
+
Setting `REDIS_URL` is enough when realtime is enabled; the explicit
|
|
870
|
+
`realtime.redis` option is useful when the URL comes from another source.
|
|
871
|
+
`realtime: true` without Redis selects a process-local memory transport, which
|
|
872
|
+
is correct for `BUNDERSTACK_ROLE=all`.
|
|
873
|
+
|
|
874
|
+
`app.runWorker()` rejects the unsafe standalone memory-transport combination
|
|
875
|
+
rather than silently losing cross-process events. If handlers never publish
|
|
876
|
+
realtime events, say so explicitly:
|
|
877
|
+
|
|
878
|
+
```ts
|
|
879
|
+
await app.runWorker({ allowProcessLocalRealtime: true })
|
|
880
|
+
```
|
|
881
|
+
|
|
882
|
+
Inspect the selected transport through `app.realtime.transport`
|
|
883
|
+
(`'disabled'`, `'memory'`, or `'redis'`). The deploy manifest exposes the
|
|
884
|
+
configured value as `app.manifest.realtimeTransport`.
|
|
885
|
+
|
|
886
|
+
## Standalone job handlers
|
|
887
|
+
|
|
888
|
+
When extracting handler logic into separate files, annotate `ctx` with
|
|
889
|
+
`BunderstackJobContext`:
|
|
890
|
+
|
|
891
|
+
```ts
|
|
892
|
+
// src/server/jobs/generate-resume.ts
|
|
893
|
+
import type { BunderstackJobContext } from 'bunderstack'
|
|
894
|
+
|
|
895
|
+
export async function generateResume(
|
|
896
|
+
adaptationId: string,
|
|
897
|
+
ctx: BunderstackJobContext,
|
|
898
|
+
) {
|
|
899
|
+
const url = await ctx.storage.getUrl(`adaptations/${adaptationId}/resume.pdf`)
|
|
900
|
+
await ctx.email.send({ ... })
|
|
901
|
+
}
|
|
902
|
+
```
|
|
903
|
+
|
|
904
|
+
## Retries and failures
|
|
905
|
+
|
|
906
|
+
A handler that throws is retried with jittered exponential backoff until
|
|
907
|
+
`retries` is exhausted, then the row is marked `failed` with its error and
|
|
908
|
+
`onFailed` fires once. Failed rows are never reaped, so they remain queryable
|
|
909
|
+
in `_bunderstack_jobs`. Succeeded rows are removed after 24 hours.
|
|
910
|
+
|
|
911
|
+
Long cron work should enqueue a queue job and return quickly rather than
|
|
912
|
+
holding its lease for minutes.
|
|
913
|
+
|
|
914
|
+
CONFIGURATION
|
|
915
|
+
|
|
916
|
+
## Application options
|
|
917
|
+
|
|
918
|
+
```ts
|
|
919
|
+
const backend = bunderstack({
|
|
920
|
+
schema,
|
|
921
|
+
database: {
|
|
922
|
+
adapter: libsql(),
|
|
923
|
+
url,
|
|
924
|
+
authToken,
|
|
925
|
+
migrations: './migrations',
|
|
926
|
+
},
|
|
927
|
+
access,
|
|
928
|
+
auth,
|
|
929
|
+
authResolver,
|
|
930
|
+
storage,
|
|
931
|
+
email,
|
|
932
|
+
env,
|
|
933
|
+
jobs: (j) => j.define({}),
|
|
934
|
+
api,
|
|
935
|
+
middleware: [instrumentation],
|
|
936
|
+
background: { autoStart: true },
|
|
937
|
+
rateLimit: { windowMs: 60_000, max: 100 },
|
|
938
|
+
idempotency: { ttlMs: 86_400_000 },
|
|
939
|
+
realtime: { bufferSize: 1_000, resumeSeconds: 300, redis },
|
|
940
|
+
openapi: true,
|
|
941
|
+
})
|
|
942
|
+
|
|
943
|
+
const app = await backend.start()
|
|
944
|
+
```
|
|
945
|
+
|
|
946
|
+
`schema` and `database.adapter` are required. All validation slots accept
|
|
947
|
+
Standard Schema. `api` takes your oRPC router — an object built from bases you
|
|
948
|
+
declared with [`defineApi`](/docs/api-procedures#declare-the-builder), or a
|
|
949
|
+
callback receiving the framework builder. `middleware` applies oRPC middleware
|
|
950
|
+
to every procedure in the graph, generated ones included; see
|
|
951
|
+
[Middleware](/docs/middleware). `openapi` serves the optional projection at
|
|
952
|
+
`/api/openapi.json`. `auth` takes better-auth options directly, or
|
|
953
|
+
a builder `({ db, env }) => BetterAuthConfig` when database hooks need the app's
|
|
954
|
+
own connection — see [Auth](/docs/auth#database-hooks).
|
|
955
|
+
|
|
956
|
+
## Realtime transport
|
|
957
|
+
|
|
958
|
+
| Configuration | Transport | Intended use |
|
|
959
|
+
| --------------------------------- | --------- | --------------------------------------- |
|
|
960
|
+
| omitted or `false` | disabled | no subscriptions or publications |
|
|
961
|
+
| `realtime: true` | memory | one application process |
|
|
962
|
+
| `realtime: true` plus `REDIS_URL` | Redis | separate web and worker processes |
|
|
963
|
+
| `realtime: { redis }` | Redis | explicit shared publisher configuration |
|
|
964
|
+
|
|
965
|
+
`bufferSize` and `resumeSeconds` configure Publisher retention. Heartbeats and
|
|
966
|
+
exponential reconnect belong to the client transport and require no
|
|
967
|
+
application setting.
|
|
968
|
+
|
|
969
|
+
When a standalone worker uses the memory publisher,
|
|
970
|
+
`app.runWorker()` rejects startup unless
|
|
971
|
+
`allowProcessLocalRealtime: true` is explicit. This prevents silently
|
|
972
|
+
publishing events that cannot reach another process.
|
|
973
|
+
|
|
974
|
+
## Database adapters
|
|
975
|
+
|
|
976
|
+
| Import | Dialect | Optional peer | Typical URL |
|
|
977
|
+
| ------------------------- | -------- | ---------------------- | --------------------------------- |
|
|
978
|
+
| `bunderstack/libsql` | SQLite | `@libsql/client` | `file:./data.db`, `libsql://…` |
|
|
979
|
+
| `bunderstack/bun-sqlite` | SQLite | none | `file:./data.db`, `:memory:` |
|
|
980
|
+
| `bunderstack/pglite` | Postgres | `@electric-sql/pglite` | `file:./data.pglite`, `memory://` |
|
|
981
|
+
| `bunderstack/bun-sql` | Postgres | none | `postgres://…` |
|
|
982
|
+
| `bunderstack/postgres-js` | Postgres | `postgres` | `postgres://…` |
|
|
983
|
+
|
|
984
|
+
The adapter dialect must match the Drizzle schema. Import only the adapter you
|
|
985
|
+
use so optional drivers stay outside the application dependency graph.
|
|
986
|
+
|
|
987
|
+
## Email and storage adapters
|
|
988
|
+
|
|
989
|
+
Email defaults to the console provider in development. Use Resend directly or
|
|
990
|
+
the optional SMTP adapter:
|
|
991
|
+
|
|
992
|
+
```ts
|
|
993
|
+
import { smtp } from 'bunderstack/email-smtp'
|
|
994
|
+
|
|
995
|
+
email: {
|
|
996
|
+
from: 'My app <hello@example.com>',
|
|
997
|
+
provider: smtp({ url: process.env.SMTP_URL! }),
|
|
998
|
+
}
|
|
999
|
+
```
|
|
1000
|
+
|
|
1001
|
+
Storage may use a local directory or S3-compatible infrastructure. Buckets
|
|
1002
|
+
carry their own upload, access, and image-transform rules; see
|
|
1003
|
+
[Storage](/docs/storage).
|
|
1004
|
+
|
|
1005
|
+
## Declaration and lifecycle
|
|
1006
|
+
|
|
1007
|
+
`await app.close()` stops background work and closes application-owned database
|
|
1008
|
+
and Publisher resources. `app.status`, `app.signal`, and
|
|
1009
|
+
`app.backgroundRunning` expose lifecycle state.
|
|
1010
|
+
|
|
1011
|
+
`bunderstack()` is synchronous and side-effect-free. Deployment tooling imports
|
|
1012
|
+
the exported backend and reads `backend.manifest` without opening database or
|
|
1013
|
+
Redis connections. `backend.start({ env })` owns production runtime resources;
|
|
1014
|
+
`backend.test()` creates an isolated, lexically owned test fixture.
|
|
1015
|
+
|
|
1016
|
+
For test suites, declare reusable defaults and setup with
|
|
1017
|
+
`backend.test.configure({ env, database, logs, setup })`. A call to the returned
|
|
1018
|
+
factory may override its defaults; `env` and `database` are deep-merged. The value
|
|
1019
|
+
returned from `setup` is exposed as `fixture.context`, and
|
|
1020
|
+
`fixture.defer(cleanup)` attaches additional resources to the fixture lifecycle.
|
|
1021
|
+
|
|
1022
|
+
## Common environment variables
|
|
1023
|
+
|
|
1024
|
+
| Variable | Purpose |
|
|
1025
|
+
| --------------------------------------- | ------------------------------------------ |
|
|
1026
|
+
| `DATABASE_URL` | selected database connection |
|
|
1027
|
+
| `DATABASE_AUTH_TOKEN` | hosted libSQL authentication |
|
|
1028
|
+
| `AUTH_SECRET` | Better Auth secret; required in production |
|
|
1029
|
+
| `REDIS_URL` | shared Publisher for split processes |
|
|
1030
|
+
| `RESEND_API_KEY` | Resend provider |
|
|
1031
|
+
| `SMTP_URL` | optional SMTP adapter |
|
|
1032
|
+
| `S3_BUCKET`, `S3_REGION`, `S3_ENDPOINT` | S3-compatible storage |
|
|
1033
|
+
| `BUNDERSTACK_ROLE` | `all`, `web`, or `worker` |
|
|
1034
|
+
|
|
1035
|
+
AUTO CRUD
|
|
1036
|
+
|
|
1037
|
+
Bunderstack generates secured oRPC procedures from your Drizzle schema. The
|
|
1038
|
+
same procedures are available through the typed client and ordinary HTTP.
|
|
1039
|
+
|
|
1040
|
+
## Procedure graph and HTTP routes
|
|
1041
|
+
|
|
1042
|
+
| Client procedure | HTTP | Description |
|
|
1043
|
+
| -------------------- | ------------------------ | ----------------- |
|
|
1044
|
+
| `api.<table>.list` | `GET /api/:table` | List and paginate |
|
|
1045
|
+
| `api.<table>.get` | `GET /api/:table/:id` | Get by id |
|
|
1046
|
+
| `api.<table>.create` | `POST /api/:table` | Create |
|
|
1047
|
+
| `api.<table>.update` | `PATCH /api/:table/:id` | Update |
|
|
1048
|
+
| `api.<table>.delete` | `DELETE /api/:table/:id` | Delete |
|
|
1049
|
+
|
|
1050
|
+
## List query
|
|
1051
|
+
|
|
1052
|
+
Every parameter below is part of the procedure's schema, so REST and RPC accept
|
|
1053
|
+
exactly the same thing and query strings are coerced to the column types.
|
|
1054
|
+
|
|
1055
|
+
| Param | Example | Description |
|
|
1056
|
+
| --------- | ---------------------------------- | ------------------------------------------------ |
|
|
1057
|
+
| `limit` | `?limit=20` | Page size (default 20, clamped to 200) |
|
|
1058
|
+
| `offset` | `?offset=0` | Skip rows (offset mode) |
|
|
1059
|
+
| `sort` | `?sort=createdAt` | Sort column (must be in `sortableColumns`) |
|
|
1060
|
+
| `order` | `?order=desc` | `asc` or `desc` |
|
|
1061
|
+
| `q` | `?q=hello` | Text search on `searchableColumns` |
|
|
1062
|
+
| `count` | `?count=true` | Include `total` in response |
|
|
1063
|
+
| `cursor` | `?cursor=...` | Keyset pagination (cannot combine with `offset`) |
|
|
1064
|
+
| `filters` | `?filters[replyToId]=5` | Equality filter on a `filterableColumns` column |
|
|
1065
|
+
| `filters` | `?filters[id][]=a&filters[id][]=b` | `IN (...)` — pass a list |
|
|
1066
|
+
| `filters` | `?filters[replyToId]=null` | `IS NULL` |
|
|
1067
|
+
|
|
1068
|
+
Anything else is rejected with 400: a bare `?replyToId=5` is not a filter, and an
|
|
1069
|
+
unknown filter column or a value the column cannot hold fails validation with a
|
|
1070
|
+
`details` entry naming the field.
|
|
1071
|
+
|
|
1072
|
+
List response:
|
|
1073
|
+
|
|
1074
|
+
```json
|
|
1075
|
+
{
|
|
1076
|
+
"items": [],
|
|
1077
|
+
"limit": 20,
|
|
1078
|
+
"offset": 0,
|
|
1079
|
+
"hasMore": true,
|
|
1080
|
+
"total": 42,
|
|
1081
|
+
"sort": "createdAt",
|
|
1082
|
+
"order": "desc",
|
|
1083
|
+
"nextCursor": "..."
|
|
1084
|
+
}
|
|
1085
|
+
```
|
|
1086
|
+
|
|
1087
|
+
`hasMore` is always returned. Use `count=true` when you need an exact `total`.
|
|
1088
|
+
|
|
1089
|
+
### Cursor vs offset
|
|
1090
|
+
|
|
1091
|
+
- **Offset** — simple, good for admin UIs and small datasets
|
|
1092
|
+
- **Cursor** — stable for feeds; pass `nextCursor` from the previous response with the same `sort` and `order`
|
|
1093
|
+
|
|
1094
|
+
```bash
|
|
1095
|
+
GET /api/posts?limit=20&sort=createdAt&order=desc
|
|
1096
|
+
GET /api/posts?limit=20&sort=createdAt&order=desc&cursor=<nextCursor>
|
|
1097
|
+
```
|
|
1098
|
+
|
|
1099
|
+
Typed calls use structured inputs rather than URL encoding:
|
|
1100
|
+
|
|
1101
|
+
```ts
|
|
1102
|
+
const page = await api.posts.list.call({
|
|
1103
|
+
filters: { replyToId: null },
|
|
1104
|
+
sort: 'createdAt',
|
|
1105
|
+
order: 'desc',
|
|
1106
|
+
limit: 20,
|
|
1107
|
+
})
|
|
1108
|
+
const created = await api.posts.create.call({ title: 'Hello' })
|
|
1109
|
+
```
|
|
1110
|
+
|
|
1111
|
+
## Which tables get routes
|
|
1112
|
+
|
|
1113
|
+
A table gets CRUD routes when it has a `userId` column (convention) **or** when you explicitly configure it in `access`. Auth tables (`user`, `session`, `account`, `verification`) are excluded by default.
|
|
1114
|
+
|
|
1115
|
+
## Access configuration
|
|
1116
|
+
|
|
1117
|
+
```ts
|
|
1118
|
+
// access.ts
|
|
1119
|
+
import { defineAccess } from 'bunderstack/access'
|
|
1120
|
+
import * as schema from './schema'
|
|
1121
|
+
|
|
1122
|
+
export const access = defineAccess(schema, {
|
|
1123
|
+
posts: {
|
|
1124
|
+
ownerColumn: 'userId',
|
|
1125
|
+
list: 'public',
|
|
1126
|
+
get: 'public',
|
|
1127
|
+
create: 'authenticated',
|
|
1128
|
+
update: 'owner',
|
|
1129
|
+
delete: 'owner',
|
|
1130
|
+
searchableColumns: ['title', 'body'],
|
|
1131
|
+
filterableColumns: ['replyToId', 'userId'],
|
|
1132
|
+
sortableColumns: ['createdAt', 'id'],
|
|
1133
|
+
defaultSort: { column: 'createdAt', order: 'desc' },
|
|
1134
|
+
},
|
|
1135
|
+
comments: {
|
|
1136
|
+
ownerColumn: 'userId',
|
|
1137
|
+
list: 'authenticated',
|
|
1138
|
+
update: (ctx) => ctx.user?.id === ctx.row?.userId,
|
|
1139
|
+
},
|
|
1140
|
+
})
|
|
1141
|
+
```
|
|
1142
|
+
|
|
1143
|
+
`defineAccess` validates all column names against the schema at startup, so typos fail fast.
|
|
1144
|
+
|
|
1145
|
+
### Rule values
|
|
1146
|
+
|
|
1147
|
+
- `'public'` — no session required
|
|
1148
|
+
- `'authenticated'` — session required
|
|
1149
|
+
- `'owner'` — session required; row owner must match `ownerColumn`
|
|
1150
|
+
- `'deny'` — always forbidden
|
|
1151
|
+
- `(ctx: AccessContext) => boolean | Promise<boolean>` — custom check
|
|
1152
|
+
|
|
1153
|
+
### Default rules (when ownerColumn is set)
|
|
1154
|
+
|
|
1155
|
+
| Operation | Default |
|
|
1156
|
+
| ------------------ | ----------------------------------------------------------------------------- |
|
|
1157
|
+
| `GET` list / by id | Public |
|
|
1158
|
+
| `POST` create | Public — owner column is **server-set** from session, never trusted from body |
|
|
1159
|
+
| `PATCH` / `DELETE` | Owner only |
|
|
1160
|
+
|
|
1161
|
+
### Column guards
|
|
1162
|
+
|
|
1163
|
+
- `readonlyColumns` — stripped from create/update bodies (defaults: `id`, `createdAt`, `updatedAt`, `userId`)
|
|
1164
|
+
- `writableColumns` — optional allow-list; any field not in this list is ignored on write
|
|
1165
|
+
|
|
1166
|
+
### Full-text search
|
|
1167
|
+
|
|
1168
|
+
Add `searchableColumns` to enable `?q=` on list:
|
|
1169
|
+
|
|
1170
|
+
```bash
|
|
1171
|
+
GET /api/posts?q=hello&limit=20&offset=0
|
|
1172
|
+
GET /api/posts?filters[replyToId]=5&sort=createdAt&order=asc
|
|
1173
|
+
```
|
|
1174
|
+
|
|
1175
|
+
### Filters and sorting
|
|
1176
|
+
|
|
1177
|
+
Add `filterableColumns` to allow filtering on a column (`?filters[column]=value`). Add `sortableColumns` and optional `defaultSort`:
|
|
1178
|
+
|
|
1179
|
+
```ts
|
|
1180
|
+
posts: {
|
|
1181
|
+
filterableColumns: ['replyToId', 'userId'],
|
|
1182
|
+
sortableColumns: ['createdAt', 'id'],
|
|
1183
|
+
defaultSort: { column: 'createdAt', order: 'desc' },
|
|
1184
|
+
}
|
|
1185
|
+
```
|
|
1186
|
+
|
|
1187
|
+
Filter values are typed by the column: `?filters[likes]=5` arrives as a number,
|
|
1188
|
+
`?filters[createdAt]=2026-06-01` as a `Date`, and `?filters[replyToId]=null`
|
|
1189
|
+
matches top-level posts. Typed clients get the same shape with autocomplete:
|
|
1190
|
+
|
|
1191
|
+
```ts
|
|
1192
|
+
await api.posts.list.call({ filters: { replyToId: null }, limit: 20 })
|
|
1193
|
+
```
|
|
1194
|
+
|
|
1195
|
+
## Error responses
|
|
1196
|
+
|
|
1197
|
+
Errors return `{ error, code?, details? }`:
|
|
1198
|
+
|
|
1199
|
+
| Code | Status | When |
|
|
1200
|
+
| ------------------- | ------ | ------------------------------------------ |
|
|
1201
|
+
| `BAD_REQUEST` | 400 | Bad query params or JSON body |
|
|
1202
|
+
| `INVALID_CURSOR` | 400 | Malformed or mismatched cursor |
|
|
1203
|
+
| `UNAUTHORIZED` | 401 | Authentication required |
|
|
1204
|
+
| `FORBIDDEN` | 403 | Access denied |
|
|
1205
|
+
| `NOT_FOUND` | 404 | Missing record |
|
|
1206
|
+
| `CONFLICT` | 409 | Idempotency key reused with different body |
|
|
1207
|
+
| `TOO_MANY_REQUESTS` | 429 | Rate limit exceeded |
|
|
1208
|
+
|
|
1209
|
+
Codes are oRPC's own, so the HTTP status always matches the code. Sub-codes that
|
|
1210
|
+
carry extra meaning (`INVALID_CURSOR`, `IDEMPOTENCY_CONFLICT`) arrive in
|
|
1211
|
+
`details.code`.
|
|
1212
|
+
|
|
1213
|
+
## Rate limiting (opt-in)
|
|
1214
|
+
|
|
1215
|
+
```ts
|
|
1216
|
+
bunderstack({
|
|
1217
|
+
schema,
|
|
1218
|
+
rateLimit: { windowMs: 60_000, max: 100 },
|
|
1219
|
+
})
|
|
1220
|
+
```
|
|
1221
|
+
|
|
1222
|
+
In-memory per process — use a shared store for multi-instance deployments.
|
|
1223
|
+
|
|
1224
|
+
## POST idempotency (opt-in)
|
|
1225
|
+
|
|
1226
|
+
```ts
|
|
1227
|
+
bunderstack({
|
|
1228
|
+
schema,
|
|
1229
|
+
idempotency: true,
|
|
1230
|
+
})
|
|
1231
|
+
```
|
|
1232
|
+
|
|
1233
|
+
Send `Idempotency-Key: <uuid>` on `POST` creates. Replays return the original response with `Idempotency-Replayed: true`.
|
|
1234
|
+
|
|
1235
|
+
## Exposing the user table
|
|
1236
|
+
|
|
1237
|
+
The `user` auth table can be opted into CRUD (for public profiles, avatar updates, etc.):
|
|
1238
|
+
|
|
1239
|
+
```ts
|
|
1240
|
+
export const access = defineAccess(schema, {
|
|
1241
|
+
user: {
|
|
1242
|
+
exposeAuthTable: true,
|
|
1243
|
+
ownerColumn: 'id',
|
|
1244
|
+
list: 'public',
|
|
1245
|
+
get: 'public',
|
|
1246
|
+
create: 'deny',
|
|
1247
|
+
update: 'owner',
|
|
1248
|
+
delete: 'deny',
|
|
1249
|
+
writableColumns: ['image', 'about'],
|
|
1250
|
+
searchableColumns: ['name'],
|
|
1251
|
+
},
|
|
1252
|
+
})
|
|
1253
|
+
```
|
|
1254
|
+
|
|
1255
|
+
## Disabling CRUD for a table
|
|
1256
|
+
|
|
1257
|
+
```ts
|
|
1258
|
+
export const access = defineAccess(schema, {
|
|
1259
|
+
session: { crud: false },
|
|
1260
|
+
account: { crud: false },
|
|
1261
|
+
verification: { crud: false },
|
|
1262
|
+
})
|
|
1263
|
+
```
|
|
1264
|
+
|
|
1265
|
+
## Application-specific behavior
|
|
1266
|
+
|
|
1267
|
+
Use `context.db` inside an [API procedure](/docs/api-procedures) when generated
|
|
1268
|
+
CRUD is not the right domain operation:
|
|
1269
|
+
|
|
1270
|
+
```ts
|
|
1271
|
+
api: (o) => ({
|
|
1272
|
+
archiveOwnPosts: o.protected.handler(({ context }) =>
|
|
1273
|
+
context.db
|
|
1274
|
+
.update(schema.posts)
|
|
1275
|
+
.set({ archived: true })
|
|
1276
|
+
.where(eq(schema.posts.userId, context.user.id)),
|
|
1277
|
+
),
|
|
1278
|
+
})
|
|
1279
|
+
```
|
|
1280
|
+
|
|
1281
|
+
DEPLOYMENT CONTRACT
|
|
1282
|
+
|
|
1283
|
+
A Bunderstack application tells a platform what it needs in two places: the
|
|
1284
|
+
committed `bunderstack.blueprint.yaml`, read before anything is deployed, and
|
|
1285
|
+
`GET /api/readiness`, asked after a release is live.
|
|
1286
|
+
|
|
1287
|
+
## Reading the blueprint
|
|
1288
|
+
|
|
1289
|
+
```ts
|
|
1290
|
+
import { parseBlueprintYaml, isSensitiveEnvVar } from 'bunderstack/blueprint'
|
|
1291
|
+
|
|
1292
|
+
const blueprint = parseBlueprintYaml(source)
|
|
1293
|
+
```
|
|
1294
|
+
|
|
1295
|
+
Unknown sections are preserved, not rejected. An application upgrades
|
|
1296
|
+
Bunderstack on its own schedule, so a blueprint may carry sections your parser
|
|
1297
|
+
predates — read what you know and ignore the rest.
|
|
1298
|
+
|
|
1299
|
+
The blueprint never contains values. No environment values, no credentials, no
|
|
1300
|
+
connection strings.
|
|
1301
|
+
|
|
1302
|
+
### Environment
|
|
1303
|
+
|
|
1304
|
+
```yaml
|
|
1305
|
+
environment:
|
|
1306
|
+
- key: STRIPE_SECRET_KEY
|
|
1307
|
+
required: true
|
|
1308
|
+
scope: server
|
|
1309
|
+
sensitive: true
|
|
1310
|
+
description: Secret key from the Stripe dashboard
|
|
1311
|
+
- key: PUBLIC_APP_NAME
|
|
1312
|
+
required: true
|
|
1313
|
+
scope: client
|
|
1314
|
+
sensitive: false
|
|
1315
|
+
```
|
|
1316
|
+
|
|
1317
|
+
`sensitive` is optional: blueprints generated before 0.23.0 do not carry it. Use
|
|
1318
|
+
`isSensitiveEnvVar(entry)`, which falls back to the scope — server keys are
|
|
1319
|
+
secrets, client keys are not. A sensitive key belongs in whatever protected
|
|
1320
|
+
input your platform offers a human; a non-sensitive one is safe to set from
|
|
1321
|
+
automation.
|
|
1322
|
+
|
|
1323
|
+
### Application operations
|
|
1324
|
+
|
|
1325
|
+
```yaml
|
|
1326
|
+
api:
|
|
1327
|
+
operations:
|
|
1328
|
+
- handle: billing.refund
|
|
1329
|
+
operationId: billing.refund
|
|
1330
|
+
effect: mutation
|
|
1331
|
+
method: POST
|
|
1332
|
+
path: /api/billing/refund
|
|
1333
|
+
```
|
|
1334
|
+
|
|
1335
|
+
These are the procedures the application declared itself. Generated CRUD,
|
|
1336
|
+
storage, and realtime routes are not listed — derive them from
|
|
1337
|
+
`resources.database.tables` and `resources.storage.buckets`.
|
|
1338
|
+
|
|
1339
|
+
`effect` is `read`, `mutation`, or `unknown`. `unknown` means the procedure
|
|
1340
|
+
declared no HTTP route, so its effect could not be established: treat it as at
|
|
1341
|
+
least as dangerous as a mutation.
|
|
1342
|
+
|
|
1343
|
+
## Asking a running application
|
|
1344
|
+
|
|
1345
|
+
`GET /api/health` is the liveness probe and always returns `{ "status": "ok" }`
|
|
1346
|
+
from a handler that does no work. Keep using it for restart policies.
|
|
1347
|
+
|
|
1348
|
+
`GET /api/readiness` answers whether the release actually came up:
|
|
1349
|
+
|
|
1350
|
+
```json
|
|
1351
|
+
{
|
|
1352
|
+
"status": "degraded",
|
|
1353
|
+
"revision": "0a8dc9f",
|
|
1354
|
+
"checks": [
|
|
1355
|
+
{ "name": "database", "status": "ok" },
|
|
1356
|
+
{ "name": "schema", "status": "ok" },
|
|
1357
|
+
{
|
|
1358
|
+
"name": "background",
|
|
1359
|
+
"status": "degraded",
|
|
1360
|
+
"code": "backlog",
|
|
1361
|
+
"overdue": 12
|
|
1362
|
+
}
|
|
1363
|
+
]
|
|
1364
|
+
}
|
|
1365
|
+
```
|
|
1366
|
+
|
|
1367
|
+
The response is always HTTP 200; read `status`, which is `ok`, `degraded`, or
|
|
1368
|
+
`error`.
|
|
1369
|
+
|
|
1370
|
+
| Check | Meaning |
|
|
1371
|
+
| ------------ | ------------------------------------------------------------------------------------------------------------------------ |
|
|
1372
|
+
| `database` | `error` with `unreachable` — the app cannot reach its database |
|
|
1373
|
+
| `schema` | `error` with `not_provisioned` — the database is reachable but has no Bunderstack tables |
|
|
1374
|
+
| `background` | `degraded` with `backlog` and `overdue` — pending jobs are more than a minute past due, so nothing is draining the queue |
|
|
1375
|
+
|
|
1376
|
+
`skipped` means the check did not apply: the application declares no queue jobs,
|
|
1377
|
+
or an earlier check already failed.
|
|
1378
|
+
|
|
1379
|
+
Set `BUNDERSTACK_REVISION` in the deployed environment and readiness echoes it as
|
|
1380
|
+
`revision`, so a deployer can confirm the running release is the commit it asked
|
|
1381
|
+
for.
|
|
1382
|
+
|
|
1383
|
+
The endpoint is public, so results carry a fixed set of codes and never a driver
|
|
1384
|
+
message, a connection string, or a stack trace.
|
|
1385
|
+
|
|
1386
|
+
EMAIL
|
|
1387
|
+
|
|
1388
|
+
Bunderstack includes an email facade with pluggable providers. Add an `email`
|
|
1389
|
+
key to your config and use `app.email.send()` anywhere on the server.
|
|
1390
|
+
|
|
1391
|
+
## Configuration
|
|
1392
|
+
|
|
1393
|
+
```ts
|
|
1394
|
+
import { bunderstack } from 'bunderstack'
|
|
1395
|
+
import { libsql } from 'bunderstack/libsql'
|
|
1396
|
+
import * as schema from './schema'
|
|
1397
|
+
|
|
1398
|
+
export const backend = bunderstack({
|
|
1399
|
+
schema,
|
|
1400
|
+
database: {
|
|
1401
|
+
adapter: libsql(),
|
|
1402
|
+
url: 'file:./data.db',
|
|
1403
|
+
},
|
|
1404
|
+
auth: { emailAndPassword: { enabled: true } },
|
|
1405
|
+
email: {
|
|
1406
|
+
from: 'noreply@example.com',
|
|
1407
|
+
provider: 'resend', // or 'console' or a custom adapter
|
|
1408
|
+
},
|
|
1409
|
+
})
|
|
1410
|
+
|
|
1411
|
+
export const app = await backend.start()
|
|
1412
|
+
```
|
|
1413
|
+
|
|
1414
|
+
## Providers
|
|
1415
|
+
|
|
1416
|
+
### Resend
|
|
1417
|
+
|
|
1418
|
+
```bash
|
|
1419
|
+
RESEND_API_KEY=re_xxx bun run server.ts
|
|
1420
|
+
```
|
|
1421
|
+
|
|
1422
|
+
```ts
|
|
1423
|
+
email: {
|
|
1424
|
+
from: 'noreply@example.com',
|
|
1425
|
+
provider: 'resend',
|
|
1426
|
+
}
|
|
1427
|
+
```
|
|
1428
|
+
|
|
1429
|
+
Uses the [Resend API](https://resend.com). Set `RESEND_API_KEY` in your
|
|
1430
|
+
environment. Bunderstack validates it at boot when `provider: 'resend'`.
|
|
1431
|
+
|
|
1432
|
+
### SMTP
|
|
1433
|
+
|
|
1434
|
+
```bash
|
|
1435
|
+
SMTP_URL=smtps://user:pass@smtp.example.com:465 bun run server.ts
|
|
1436
|
+
```
|
|
1437
|
+
|
|
1438
|
+
```ts
|
|
1439
|
+
import { smtp } from 'bunderstack/email-smtp'
|
|
1440
|
+
|
|
1441
|
+
email: {
|
|
1442
|
+
from: 'noreply@example.com',
|
|
1443
|
+
provider: smtp({ url: process.env.SMTP_URL! }),
|
|
1444
|
+
}
|
|
1445
|
+
```
|
|
1446
|
+
|
|
1447
|
+
Uses [nodemailer](https://nodemailer.com) under the hood. Install it as an
|
|
1448
|
+
optional peer:
|
|
1449
|
+
|
|
1450
|
+
```bash
|
|
1451
|
+
bun add nodemailer
|
|
1452
|
+
```
|
|
1453
|
+
|
|
1454
|
+
The SMTP integration is isolated to this subpath, so projects that do not use
|
|
1455
|
+
it do not load Nodemailer.
|
|
1456
|
+
|
|
1457
|
+
### Console (development default)
|
|
1458
|
+
|
|
1459
|
+
When no provider is specified in development, emails are logged to the console
|
|
1460
|
+
instead of being sent. In production, omitting a provider throws at boot.
|
|
1461
|
+
|
|
1462
|
+
```ts
|
|
1463
|
+
email: {
|
|
1464
|
+
from: 'noreply@example.com'
|
|
1465
|
+
}
|
|
1466
|
+
// provider defaults to 'console' in development — logs to stdout
|
|
1467
|
+
```
|
|
1468
|
+
|
|
1469
|
+
### Custom adapter
|
|
1470
|
+
|
|
1471
|
+
Pass a full `EmailAdapter` or just a `send` function:
|
|
1472
|
+
|
|
1473
|
+
```ts
|
|
1474
|
+
email: {
|
|
1475
|
+
from: 'noreply@example.com',
|
|
1476
|
+
provider: {
|
|
1477
|
+
async send(msg) {
|
|
1478
|
+
// msg has `from` already resolved
|
|
1479
|
+
await mySendService(msg)
|
|
1480
|
+
return { id: 'msg_123' }
|
|
1481
|
+
},
|
|
1482
|
+
},
|
|
1483
|
+
}
|
|
1484
|
+
```
|
|
1485
|
+
|
|
1486
|
+
Or the function shorthand:
|
|
1487
|
+
|
|
1488
|
+
```ts
|
|
1489
|
+
email: {
|
|
1490
|
+
from: 'noreply@example.com',
|
|
1491
|
+
provider: async (msg) => {
|
|
1492
|
+
await fetch('https://my-email-api.com/send', {
|
|
1493
|
+
method: 'POST',
|
|
1494
|
+
body: JSON.stringify(msg),
|
|
1495
|
+
})
|
|
1496
|
+
return {}
|
|
1497
|
+
},
|
|
1498
|
+
}
|
|
1499
|
+
```
|
|
1500
|
+
|
|
1501
|
+
## Sending
|
|
1502
|
+
|
|
1503
|
+
```ts
|
|
1504
|
+
await app.email.send({
|
|
1505
|
+
to: 'user@example.com',
|
|
1506
|
+
subject: 'Welcome!',
|
|
1507
|
+
html: '<h1>Hello</h1>',
|
|
1508
|
+
text: 'Hello',
|
|
1509
|
+
})
|
|
1510
|
+
```
|
|
1511
|
+
|
|
1512
|
+
All fields except `subject` and one of `html`/`text` are optional. The `from`
|
|
1513
|
+
field defaults to the config's `from` but can be overridden per-message.
|
|
1514
|
+
|
|
1515
|
+
```ts
|
|
1516
|
+
await app.email.send({
|
|
1517
|
+
to: ['alice@a.com', 'bob@b.com'],
|
|
1518
|
+
subject: 'Team update',
|
|
1519
|
+
html: '<p>Hi team</p>',
|
|
1520
|
+
from: 'team@example.com', // overrides config default
|
|
1521
|
+
replyTo: 'support@example.com',
|
|
1522
|
+
cc: 'manager@example.com',
|
|
1523
|
+
bcc: 'archive@example.com',
|
|
1524
|
+
})
|
|
1525
|
+
```
|
|
1526
|
+
|
|
1527
|
+
Returns `{ id?: string }` — the provider-specific message ID when available.
|
|
1528
|
+
|
|
1529
|
+
## BetterAuth auto-wiring
|
|
1530
|
+
|
|
1531
|
+
When you configure an `email` key, Bunderstack automatically wires it into
|
|
1532
|
+
BetterAuth for email verification and password reset flows. No extra config
|
|
1533
|
+
needed — just add `email` to `bunderstack`.
|
|
1534
|
+
|
|
1535
|
+
## Without email
|
|
1536
|
+
|
|
1537
|
+
If you don't need email, omit the `email` key entirely. `app.email` is still
|
|
1538
|
+
present on the app, but calling `send()` throws with a descriptive error.
|
|
1539
|
+
|
|
1540
|
+
ENVIRONMENT VALIDATION
|
|
1541
|
+
|
|
1542
|
+
Bunderstack validates environment values before the application starts.
|
|
1543
|
+
Server-only and public values stay separate, while `app.env` and procedure
|
|
1544
|
+
contexts remain fully typed.
|
|
1545
|
+
|
|
1546
|
+
## Configure schemas
|
|
1547
|
+
|
|
1548
|
+
Each entry accepts Standard Schema. This example uses Valibot:
|
|
1549
|
+
|
|
1550
|
+
```ts
|
|
1551
|
+
import * as v from 'valibot'
|
|
1552
|
+
|
|
1553
|
+
export const env = {
|
|
1554
|
+
server: {
|
|
1555
|
+
STRIPE_API_KEY: v.string(),
|
|
1556
|
+
WEBHOOK_SECRET: v.string(),
|
|
1557
|
+
},
|
|
1558
|
+
client: {
|
|
1559
|
+
PUBLIC_APP_URL: v.pipe(v.string(), v.url()),
|
|
1560
|
+
PUBLIC_SENTRY_DSN: v.optional(v.string()),
|
|
1561
|
+
},
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
export const backend = bunderstack({ schema, database, env })
|
|
1565
|
+
export const app = await backend.start()
|
|
1566
|
+
```
|
|
1567
|
+
|
|
1568
|
+
Server keys must not start with `PUBLIC_`; browser-safe keys must. Naming
|
|
1569
|
+
violations and invalid values produce a `BunderstackEnvError` with all issues,
|
|
1570
|
+
not only the first one.
|
|
1571
|
+
|
|
1572
|
+
## Describe keys for deployment
|
|
1573
|
+
|
|
1574
|
+
Hosting platforms read the committed blueprint to work out what an environment
|
|
1575
|
+
needs before the first deploy. `meta` adds value-free metadata per key:
|
|
1576
|
+
|
|
1577
|
+
```ts
|
|
1578
|
+
export const env = {
|
|
1579
|
+
server: {
|
|
1580
|
+
STRIPE_API_KEY: v.string(),
|
|
1581
|
+
LOG_LEVEL: v.optional(v.string()),
|
|
1582
|
+
},
|
|
1583
|
+
client: {
|
|
1584
|
+
PUBLIC_APP_URL: v.pipe(v.string(), v.url()),
|
|
1585
|
+
},
|
|
1586
|
+
meta: {
|
|
1587
|
+
STRIPE_API_KEY: { description: 'Secret key from the Stripe dashboard' },
|
|
1588
|
+
LOG_LEVEL: { sensitive: false, description: 'debug | info | warn | error' },
|
|
1589
|
+
},
|
|
1590
|
+
}
|
|
1591
|
+
```
|
|
1592
|
+
|
|
1593
|
+
Server keys are treated as secrets by default and client keys never are — a
|
|
1594
|
+
`PUBLIC_*` value is compiled into the browser bundle, so declaring one sensitive
|
|
1595
|
+
is an error. Descriptions are static prose, at most 200 characters.
|
|
1596
|
+
|
|
1597
|
+
Values never reach the blueprint. Only the key name, whether it is required, its
|
|
1598
|
+
scope, its secrecy, and its description do.
|
|
1599
|
+
|
|
1600
|
+
## Use validated values
|
|
1601
|
+
|
|
1602
|
+
```ts
|
|
1603
|
+
app.env.STRIPE_API_KEY
|
|
1604
|
+
app.env.PUBLIC_APP_URL
|
|
1605
|
+
|
|
1606
|
+
api: (o) => ({
|
|
1607
|
+
publicConfig: o.public.handler(({ context }) => ({
|
|
1608
|
+
appUrl: context.env.PUBLIC_APP_URL,
|
|
1609
|
+
})),
|
|
1610
|
+
})
|
|
1611
|
+
```
|
|
1612
|
+
|
|
1613
|
+
## Browser values
|
|
1614
|
+
|
|
1615
|
+
`createClientEnv()` validates only the `client` section. Server keys become
|
|
1616
|
+
runtime traps if code tries to access them in a browser bundle.
|
|
1617
|
+
|
|
1618
|
+
```ts
|
|
1619
|
+
import { createClientEnv } from 'bunderstack/env'
|
|
1620
|
+
import { env } from './env-schema'
|
|
1621
|
+
|
|
1622
|
+
export const clientEnv = createClientEnv({
|
|
1623
|
+
...env,
|
|
1624
|
+
runtimeEnv: import.meta.env,
|
|
1625
|
+
})
|
|
1626
|
+
```
|
|
1627
|
+
|
|
1628
|
+
## Built-in values
|
|
1629
|
+
|
|
1630
|
+
Bunderstack also understands database, auth, Redis, email, and storage
|
|
1631
|
+
variables used by its own facilities. Production requires a secure
|
|
1632
|
+
`AUTH_SECRET`. Database URLs have adapter-specific development defaults; Redis
|
|
1633
|
+
is required when separate worker processes publish realtime changes.
|
|
1634
|
+
|
|
1635
|
+
```ts
|
|
1636
|
+
import { BunderstackEnvError } from 'bunderstack'
|
|
1637
|
+
|
|
1638
|
+
try {
|
|
1639
|
+
await bunderstack({ schema, database, env }).start()
|
|
1640
|
+
} catch (error) {
|
|
1641
|
+
if (error instanceof BunderstackEnvError) {
|
|
1642
|
+
console.error(error.issues)
|
|
1643
|
+
}
|
|
1644
|
+
}
|
|
1645
|
+
```
|
|
1646
|
+
|
|
1647
|
+
FRAMEWORK PORTABILITY
|
|
1648
|
+
|
|
1649
|
+
`app.handler(req: Request): Promise<Response>` — every modern TypeScript framework knows this Web Standard shape.
|
|
1650
|
+
|
|
1651
|
+
Bunderstack runs in any environment supporting standard Web Requests and Responses. Below are the recommended integration patterns, configuration files, and deployment setups for each supported framework.
|
|
1652
|
+
|
|
1653
|
+
---
|
|
1654
|
+
|
|
1655
|
+
## TanStack Start (Full-Stack SSR)
|
|
1656
|
+
|
|
1657
|
+
[TanStack Start](https://tanstack.com/start) provides full-stack React with server-side rendering and streaming. The official [`bunderstack/start`](/docs/query-client) adapter handles API routing, SSR-aware data fetching, session lookup, and auth client configuration.
|
|
1658
|
+
|
|
1659
|
+
### 1. Installation
|
|
1660
|
+
|
|
1661
|
+
```bash
|
|
1662
|
+
bun add bunderstack drizzle-orm valibot @libsql/client @tanstack/react-query @tanstack/react-start
|
|
1663
|
+
```
|
|
1664
|
+
|
|
1665
|
+
### 2. Configuration (`src/bunderstack.ts`)
|
|
1666
|
+
|
|
1667
|
+
TanStack Start supports top-level `await`, allowing you to export `app` and its TypeScript type directly:
|
|
1668
|
+
|
|
1669
|
+
```ts
|
|
1670
|
+
// src/bunderstack.ts
|
|
1671
|
+
import { bunderstack } from 'bunderstack'
|
|
1672
|
+
import { libsql } from 'bunderstack/libsql'
|
|
1673
|
+
import { provision } from 'bunderstack/provision'
|
|
1674
|
+
import { access } from './access'
|
|
1675
|
+
import * as schema from './schema'
|
|
1676
|
+
|
|
1677
|
+
export const backend = bunderstack({
|
|
1678
|
+
schema,
|
|
1679
|
+
access,
|
|
1680
|
+
database: {
|
|
1681
|
+
adapter: libsql(),
|
|
1682
|
+
url: process.env.DATABASE_URL || 'file:./data.db',
|
|
1683
|
+
},
|
|
1684
|
+
auth: { emailAndPassword: { enabled: true } },
|
|
1685
|
+
realtime: true,
|
|
1686
|
+
})
|
|
1687
|
+
|
|
1688
|
+
export const app = await backend.start()
|
|
1689
|
+
export type App = typeof app
|
|
1690
|
+
await provision(app)
|
|
1691
|
+
```
|
|
1692
|
+
|
|
1693
|
+
### 3. API Catch-All Route (`src/routes/api/$.tsx`)
|
|
1694
|
+
|
|
1695
|
+
Mount the API handler on TanStack Start's catch-all route:
|
|
1696
|
+
|
|
1697
|
+
```ts
|
|
1698
|
+
// src/routes/api/$.tsx
|
|
1699
|
+
import { createFileRoute } from '@tanstack/react-router'
|
|
1700
|
+
import { createApiHandlers } from 'bunderstack/start'
|
|
1701
|
+
import { app } from '~/bunderstack'
|
|
1702
|
+
|
|
1703
|
+
export const Route = createFileRoute('/api/$')({
|
|
1704
|
+
server: { handlers: createApiHandlers(app) },
|
|
1705
|
+
})
|
|
1706
|
+
```
|
|
1707
|
+
|
|
1708
|
+
### 4. Typed Client Setup (`src/api.ts`)
|
|
1709
|
+
|
|
1710
|
+
```ts
|
|
1711
|
+
// src/api.ts
|
|
1712
|
+
import { bunderstackStart } from 'bunderstack/start'
|
|
1713
|
+
import type { App } from './bunderstack'
|
|
1714
|
+
|
|
1715
|
+
export const { createQueryClient, createApi } = bunderstackStart<App>()
|
|
1716
|
+
```
|
|
1717
|
+
|
|
1718
|
+
> **Important:** Do not name this file `src/client.ts`. TanStack Start reserves `client.ts` as its hydration entry point.
|
|
1719
|
+
|
|
1720
|
+
### 5. Package Scripts & Deployment
|
|
1721
|
+
|
|
1722
|
+
```json
|
|
1723
|
+
{
|
|
1724
|
+
"scripts": {
|
|
1725
|
+
"build": "vite build",
|
|
1726
|
+
"start": "bun .output/server/index.mjs"
|
|
1727
|
+
}
|
|
1728
|
+
}
|
|
1729
|
+
```
|
|
1730
|
+
|
|
1731
|
+
Running `bunx bunderstack blueprint` detects `@tanstack/react-start` and outputs `framework: tanstack-start`.
|
|
1732
|
+
|
|
1733
|
+
---
|
|
1734
|
+
|
|
1735
|
+
## Solid 2 (Standalone Vite + Bun SSR)
|
|
1736
|
+
|
|
1737
|
+
Standalone [Solid 2](https://solidjs.com) (without SolidStart) uses a unified Bun server to handle static assets, perform server-side rendering, and delegate API requests directly to Bunderstack.
|
|
1738
|
+
|
|
1739
|
+
### 1. Installation
|
|
1740
|
+
|
|
1741
|
+
```bash
|
|
1742
|
+
bun add bunderstack solid-js@2.0.0-rc.1 @solidjs/web@2.0.0-rc.1 @solidjs/router@2.0.0-next.17 drizzle-orm valibot @libsql/client @orpc/client
|
|
1743
|
+
bun add -d vite vite-plugin-solid@3.0.0-next.27
|
|
1744
|
+
```
|
|
1745
|
+
|
|
1746
|
+
### 2. Configuration (`src/bunderstack.ts`)
|
|
1747
|
+
|
|
1748
|
+
```ts
|
|
1749
|
+
// src/bunderstack.ts
|
|
1750
|
+
import { bunderstack } from 'bunderstack'
|
|
1751
|
+
import { libsql } from 'bunderstack/libsql'
|
|
1752
|
+
import { provision } from 'bunderstack/provision'
|
|
1753
|
+
import * as schema from './schema'
|
|
1754
|
+
|
|
1755
|
+
export const backend = bunderstack({
|
|
1756
|
+
schema,
|
|
1757
|
+
database: {
|
|
1758
|
+
adapter: libsql(),
|
|
1759
|
+
url: process.env.DATABASE_URL || 'file:./data.db',
|
|
1760
|
+
},
|
|
1761
|
+
auth: { emailAndPassword: { enabled: true } },
|
|
1762
|
+
realtime: true,
|
|
1763
|
+
})
|
|
1764
|
+
|
|
1765
|
+
export const app = await backend.start()
|
|
1766
|
+
export type App = typeof app
|
|
1767
|
+
await provision(app)
|
|
1768
|
+
```
|
|
1769
|
+
|
|
1770
|
+
### 3. Unified HTTP Server (`src/server.ts`)
|
|
1771
|
+
|
|
1772
|
+
In production, `src/server.ts` routes all `/api/*` requests to `app.handler`, serves compiled client assets, and renders Solid 2 components via `renderToString`:
|
|
1773
|
+
|
|
1774
|
+
```tsx
|
|
1775
|
+
// src/server.ts
|
|
1776
|
+
import { renderToString } from '@solidjs/web/server'
|
|
1777
|
+
import { app } from './bunderstack'
|
|
1778
|
+
import App from './App'
|
|
1779
|
+
|
|
1780
|
+
const port = Number(process.env.PORT) || 3000
|
|
1781
|
+
|
|
1782
|
+
Bun.serve({
|
|
1783
|
+
port,
|
|
1784
|
+
async fetch(req) {
|
|
1785
|
+
const url = new URL(req.url)
|
|
1786
|
+
|
|
1787
|
+
// 1. Route API, auth, storage, jobs, and health check
|
|
1788
|
+
if (url.pathname.startsWith('/api/')) {
|
|
1789
|
+
return app.handler(req)
|
|
1790
|
+
}
|
|
1791
|
+
|
|
1792
|
+
// 2. Serve static client assets from dist/client
|
|
1793
|
+
const filePath = `dist/client${url.pathname}`
|
|
1794
|
+
const file = Bun.file(filePath)
|
|
1795
|
+
if (await file.exists()) {
|
|
1796
|
+
return new Response(file)
|
|
1797
|
+
}
|
|
1798
|
+
|
|
1799
|
+
// 3. Render Solid 2 SSR HTML
|
|
1800
|
+
const html = renderToString(() => <App url={url.pathname} />)
|
|
1801
|
+
return new Response(
|
|
1802
|
+
`<!DOCTYPE html>
|
|
1803
|
+
<html lang="en">
|
|
1804
|
+
<head>
|
|
1805
|
+
<meta charset="utf-8" />
|
|
1806
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
1807
|
+
<script type="module" src="/entry-client.js"></script>
|
|
1808
|
+
</head>
|
|
1809
|
+
<body>
|
|
1810
|
+
<div id="app">${html}</div>
|
|
1811
|
+
</body>
|
|
1812
|
+
</html>`,
|
|
1813
|
+
{ headers: { 'Content-Type': 'text/html; charset=utf-8' } },
|
|
1814
|
+
)
|
|
1815
|
+
},
|
|
1816
|
+
})
|
|
1817
|
+
|
|
1818
|
+
console.log(`Solid 2 + Bunderstack running on port ${port}`)
|
|
1819
|
+
```
|
|
1820
|
+
|
|
1821
|
+
### 4. Client Setup (`src/api.ts`)
|
|
1822
|
+
|
|
1823
|
+
```ts
|
|
1824
|
+
// src/api.ts
|
|
1825
|
+
import { createClient } from 'bunderstack/query'
|
|
1826
|
+
import { QueryClient } from '@tanstack/solid-query'
|
|
1827
|
+
import type { App } from './bunderstack'
|
|
1828
|
+
|
|
1829
|
+
export const queryClient = new QueryClient()
|
|
1830
|
+
export const api = createClient<App>({ queryClient, baseUrl: '/api' })
|
|
1831
|
+
```
|
|
1832
|
+
|
|
1833
|
+
### 5. Package Scripts & Deployment
|
|
1834
|
+
|
|
1835
|
+
```json
|
|
1836
|
+
{
|
|
1837
|
+
"bunderstack": {
|
|
1838
|
+
"entry": "src/bunderstack.ts"
|
|
1839
|
+
},
|
|
1840
|
+
"scripts": {
|
|
1841
|
+
"build": "vite build --outDir dist/client && vite build --ssr src/entry-server.tsx --outDir dist/server",
|
|
1842
|
+
"start": "bun src/server.ts"
|
|
1843
|
+
}
|
|
1844
|
+
}
|
|
1845
|
+
```
|
|
1846
|
+
|
|
1847
|
+
Running `bunx bunderstack blueprint` detects `solid-js` and sets `framework: solid`.
|
|
1848
|
+
|
|
1849
|
+
---
|
|
1850
|
+
|
|
1851
|
+
## TanStack Router / React SPA (Vite)
|
|
1852
|
+
|
|
1853
|
+
In a Single Page Application (SPA) with Vite and React (or TanStack Router), the frontend bundle runs entirely in the browser and connects to a Bunderstack backend.
|
|
1854
|
+
|
|
1855
|
+
### 1. Configuration & Server (`server.ts` or `src/bunderstack.ts`)
|
|
1856
|
+
|
|
1857
|
+
```ts
|
|
1858
|
+
// server.ts
|
|
1859
|
+
import { bunderstack } from 'bunderstack'
|
|
1860
|
+
import { libsql } from 'bunderstack/libsql'
|
|
1861
|
+
import { provision } from 'bunderstack/provision'
|
|
1862
|
+
import * as schema from './schema'
|
|
1863
|
+
|
|
1864
|
+
export const backend = bunderstack({
|
|
1865
|
+
schema,
|
|
1866
|
+
database: {
|
|
1867
|
+
adapter: libsql(),
|
|
1868
|
+
url: process.env.DATABASE_URL || 'file:./data.db',
|
|
1869
|
+
},
|
|
1870
|
+
auth: { emailAndPassword: { enabled: true } },
|
|
1871
|
+
realtime: true,
|
|
1872
|
+
})
|
|
1873
|
+
|
|
1874
|
+
export const app = await backend.start()
|
|
1875
|
+
export type App = typeof app
|
|
1876
|
+
await provision(app)
|
|
1877
|
+
|
|
1878
|
+
const port = Number(process.env.PORT) || 3000
|
|
1879
|
+
Bun.serve({
|
|
1880
|
+
port,
|
|
1881
|
+
async fetch(req) {
|
|
1882
|
+
const url = new URL(req.url)
|
|
1883
|
+
if (url.pathname.startsWith('/api/')) {
|
|
1884
|
+
return app.handler(req)
|
|
1885
|
+
}
|
|
1886
|
+
|
|
1887
|
+
const file = Bun.file(`dist/${url.pathname}`)
|
|
1888
|
+
if (await file.exists()) return new Response(file)
|
|
1889
|
+
|
|
1890
|
+
return new Response(Bun.file('dist/index.html'))
|
|
1891
|
+
},
|
|
1892
|
+
})
|
|
1893
|
+
```
|
|
1894
|
+
|
|
1895
|
+
### 2. Client Setup (`src/api.ts`)
|
|
1896
|
+
|
|
1897
|
+
```ts
|
|
1898
|
+
// src/api.ts
|
|
1899
|
+
import { createClient } from 'bunderstack/query'
|
|
1900
|
+
import { QueryClient } from '@tanstack/react-query'
|
|
1901
|
+
import type { App } from '../server'
|
|
1902
|
+
|
|
1903
|
+
export const queryClient = new QueryClient()
|
|
1904
|
+
export const api = createClient<App>({
|
|
1905
|
+
queryClient,
|
|
1906
|
+
baseUrl: '/api',
|
|
1907
|
+
})
|
|
1908
|
+
```
|
|
1909
|
+
|
|
1910
|
+
### 3. Vite Proxy for Development (`vite.config.ts`)
|
|
1911
|
+
|
|
1912
|
+
```ts
|
|
1913
|
+
// vite.config.ts
|
|
1914
|
+
import { defineConfig } from 'vite'
|
|
1915
|
+
import react from '@vitejs/plugin-react'
|
|
1916
|
+
|
|
1917
|
+
export default defineConfig({
|
|
1918
|
+
plugins: [react()],
|
|
1919
|
+
server: {
|
|
1920
|
+
proxy: {
|
|
1921
|
+
'/api': 'http://localhost:3000',
|
|
1922
|
+
},
|
|
1923
|
+
},
|
|
1924
|
+
})
|
|
1925
|
+
```
|
|
1926
|
+
|
|
1927
|
+
---
|
|
1928
|
+
|
|
1929
|
+
## Bun SSR (Pure Web Standards Bun Server)
|
|
1930
|
+
|
|
1931
|
+
For server-rendered applications using Bun with template literals, JSX, HTMX, Alpine.js, or Web Components without a full frontend framework.
|
|
1932
|
+
|
|
1933
|
+
### 1. Configuration (`src/bunderstack.ts`)
|
|
1934
|
+
|
|
1935
|
+
```ts
|
|
1936
|
+
// src/bunderstack.ts
|
|
1937
|
+
import { bunderstack } from 'bunderstack'
|
|
1938
|
+
import { libsql } from 'bunderstack/libsql'
|
|
1939
|
+
import { provision } from 'bunderstack/provision'
|
|
1940
|
+
import * as schema from './schema'
|
|
1941
|
+
|
|
1942
|
+
export const backend = bunderstack({
|
|
1943
|
+
schema,
|
|
1944
|
+
database: {
|
|
1945
|
+
adapter: libsql(),
|
|
1946
|
+
url: process.env.DATABASE_URL || 'file:./data.db',
|
|
1947
|
+
},
|
|
1948
|
+
auth: { emailAndPassword: { enabled: true } },
|
|
1949
|
+
realtime: true,
|
|
1950
|
+
})
|
|
1951
|
+
|
|
1952
|
+
export const app = await backend.start()
|
|
1953
|
+
export type App = typeof app
|
|
1954
|
+
await provision(app)
|
|
1955
|
+
```
|
|
1956
|
+
|
|
1957
|
+
### 2. Server Entry (`src/server.ts`)
|
|
1958
|
+
|
|
1959
|
+
```ts
|
|
1960
|
+
// src/server.ts
|
|
1961
|
+
import { app } from './bunderstack'
|
|
1962
|
+
|
|
1963
|
+
const port = Number(process.env.PORT) || 3000
|
|
1964
|
+
|
|
1965
|
+
Bun.serve({
|
|
1966
|
+
port,
|
|
1967
|
+
async fetch(req) {
|
|
1968
|
+
const url = new URL(req.url)
|
|
1969
|
+
|
|
1970
|
+
// Route API requests (oRPC, Auth, Storage, Jobs, Health)
|
|
1971
|
+
if (url.pathname.startsWith('/api/')) {
|
|
1972
|
+
return app.handler(req)
|
|
1973
|
+
}
|
|
1974
|
+
|
|
1975
|
+
// Server-rendered HTML response
|
|
1976
|
+
return new Response(
|
|
1977
|
+
`<!DOCTYPE html>
|
|
1978
|
+
<html>
|
|
1979
|
+
<head><title>Bun SSR App</title></head>
|
|
1980
|
+
<body>
|
|
1981
|
+
<h1>Welcome to Bunderstack on Bun SSR</h1>
|
|
1982
|
+
</body>
|
|
1983
|
+
</html>`,
|
|
1984
|
+
{ headers: { 'Content-Type': 'text/html; charset=utf-8' } },
|
|
1985
|
+
)
|
|
1986
|
+
},
|
|
1987
|
+
})
|
|
1988
|
+
```
|
|
1989
|
+
|
|
1990
|
+
### 3. Package Scripts & Deployment
|
|
1991
|
+
|
|
1992
|
+
```json
|
|
1993
|
+
{
|
|
1994
|
+
"scripts": {
|
|
1995
|
+
"build": "bun build ./src/client.ts --outdir ./dist",
|
|
1996
|
+
"start": "bun src/server.ts"
|
|
1997
|
+
}
|
|
1998
|
+
}
|
|
1999
|
+
```
|
|
2000
|
+
|
|
2001
|
+
Running `bunx bunderstack blueprint` detects a generic Bun project and assigns `framework: bun-ssr`.
|
|
2002
|
+
|
|
2003
|
+
---
|
|
2004
|
+
|
|
2005
|
+
## Next.js (App Router)
|
|
2006
|
+
|
|
2007
|
+
### 1. Lazy Singleton (`lib/bunderstack.ts`)
|
|
2008
|
+
|
|
2009
|
+
```ts
|
|
2010
|
+
// lib/bunderstack.ts
|
|
2011
|
+
import { bunderstack } from 'bunderstack'
|
|
2012
|
+
import { provision } from 'bunderstack/provision'
|
|
2013
|
+
import * as schema from './schema'
|
|
2014
|
+
|
|
2015
|
+
const backend = bunderstack({
|
|
2016
|
+
schema,
|
|
2017
|
+
auth: { emailAndPassword: { enabled: true } },
|
|
2018
|
+
})
|
|
2019
|
+
|
|
2020
|
+
let _app: Awaited<ReturnType<typeof backend.start>> | null = null
|
|
2021
|
+
|
|
2022
|
+
export async function getApp() {
|
|
2023
|
+
if (!_app) {
|
|
2024
|
+
_app = await backend.start()
|
|
2025
|
+
await provision(_app)
|
|
2026
|
+
}
|
|
2027
|
+
return _app
|
|
2028
|
+
}
|
|
2029
|
+
```
|
|
2030
|
+
|
|
2031
|
+
### 2. Route Handler (`app/api/[...bunderstack]/route.ts`)
|
|
2032
|
+
|
|
2033
|
+
```ts
|
|
2034
|
+
// app/api/[...bunderstack]/route.ts
|
|
2035
|
+
import { getApp } from '@/lib/bunderstack'
|
|
2036
|
+
|
|
2037
|
+
export async function GET(req: Request) {
|
|
2038
|
+
return (await getApp()).handler(req)
|
|
2039
|
+
}
|
|
2040
|
+
export const POST = GET
|
|
2041
|
+
export const PATCH = GET
|
|
2042
|
+
export const DELETE = GET
|
|
2043
|
+
```
|
|
2044
|
+
|
|
2045
|
+
> Next.js runs on Node.js. If connecting to a PostgreSQL database, install the Node driver: `npm install postgres`. When running under Bun, the native `Bun.sql` driver is selected automatically.
|
|
2046
|
+
|
|
2047
|
+
---
|
|
2048
|
+
|
|
2049
|
+
## Other Web Standards Runtimes
|
|
2050
|
+
|
|
2051
|
+
Because `app.handler` implements the Web Standard `(req: Request) => Promise<Response>` signature, it integrates into any standard runtime in one line:
|
|
2052
|
+
|
|
2053
|
+
### Hono
|
|
2054
|
+
|
|
2055
|
+
```ts
|
|
2056
|
+
import { Hono } from 'hono'
|
|
2057
|
+
import { app } from './bunderstack'
|
|
2058
|
+
|
|
2059
|
+
// Delegate all /api/* routes to Bunderstack
|
|
2060
|
+
const server = createHonoApp()
|
|
2061
|
+
server.all('/api/*', (c) => app.handler(c.req.raw))
|
|
2062
|
+
export default server
|
|
2063
|
+
```
|
|
2064
|
+
|
|
2065
|
+
### Astro
|
|
2066
|
+
|
|
2067
|
+
```ts
|
|
2068
|
+
// src/pages/api/[...all].ts
|
|
2069
|
+
import type { APIRoute } from 'astro'
|
|
2070
|
+
import { app } from '../../bunderstack'
|
|
2071
|
+
|
|
2072
|
+
export const ALL: APIRoute = ({ request }) => app.handler(request)
|
|
2073
|
+
```
|
|
2074
|
+
|
|
2075
|
+
---
|
|
2076
|
+
|
|
2077
|
+
## Deployment Blueprint Comparison
|
|
2078
|
+
|
|
2079
|
+
When generating a deployment contract via `bunx bunderstack blueprint`, Bunderstack identifies the application type and specifies it in `bunderstack.blueprint.yaml`:
|
|
2080
|
+
|
|
2081
|
+
| Framework Type | Blueprint `framework` | Detection Rule | Typical `build` Script | Typical `start` Script |
|
|
2082
|
+
| ------------------ | --------------------- | -------------------------------------------- | ------------------------------------ | ------------------------------ |
|
|
2083
|
+
| **TanStack Start** | `tanstack-start` | `@tanstack/react-start` in dependencies | `vite build` | `bun .output/server/index.mjs` |
|
|
2084
|
+
| **Solid 2** | `solid` | `solid-js` or `@solidjs/web` in dependencies | `vite build && vite build --ssr ...` | `bun src/server.ts` |
|
|
2085
|
+
| **Bun SSR** | `bun-ssr` | Standard Bun scripts present | `bun build ...` | `bun src/server.ts` |
|
|
2086
|
+
| **Custom / SPA** | `custom` | Custom specified framework | Custom build command | Custom startup command |
|
|
2087
|
+
|
|
2088
|
+
GETTING STARTED
|
|
2089
|
+
|
|
2090
|
+
## Install
|
|
2091
|
+
|
|
2092
|
+
```bash
|
|
2093
|
+
bun add bunderstack drizzle-orm valibot @libsql/client @tanstack/react-query
|
|
2094
|
+
bun add -d drizzle-kit
|
|
2095
|
+
```
|
|
2096
|
+
|
|
2097
|
+
## Define a schema
|
|
2098
|
+
|
|
2099
|
+
Use the Drizzle builders directly and re-export Bunderstack's internal tables
|
|
2100
|
+
so storage and runtime metadata are included in migrations.
|
|
2101
|
+
|
|
2102
|
+
```ts
|
|
2103
|
+
// schema.ts
|
|
2104
|
+
import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'
|
|
2105
|
+
|
|
2106
|
+
export * from 'bunderstack/schema'
|
|
2107
|
+
|
|
2108
|
+
export const posts = sqliteTable('posts', {
|
|
2109
|
+
id: text('id').primaryKey(),
|
|
2110
|
+
title: text('title').notNull(),
|
|
2111
|
+
userId: text('userId').notNull(),
|
|
2112
|
+
createdAt: integer('createdAt', { mode: 'timestamp' })
|
|
2113
|
+
.notNull()
|
|
2114
|
+
.$defaultFn(() => new Date()),
|
|
2115
|
+
})
|
|
2116
|
+
```
|
|
2117
|
+
|
|
2118
|
+
Better Auth also needs its `user`, `session`, `account`, and `verification`
|
|
2119
|
+
tables. Copy the matching SQLite or Postgres definitions from an example or
|
|
2120
|
+
the SaaS template.
|
|
2121
|
+
|
|
2122
|
+
## Create the app
|
|
2123
|
+
|
|
2124
|
+
The custom procedure below joins the same graph as generated CRUD. Valibot is
|
|
2125
|
+
used for runtime input validation; the handler return type becomes the output
|
|
2126
|
+
contract automatically.
|
|
2127
|
+
|
|
2128
|
+
```ts
|
|
2129
|
+
// bunderstack.ts
|
|
2130
|
+
import { bunderstack } from 'bunderstack'
|
|
2131
|
+
import { libsql } from 'bunderstack/libsql'
|
|
2132
|
+
import { provision } from 'bunderstack/provision'
|
|
2133
|
+
import { count } from 'drizzle-orm'
|
|
2134
|
+
import * as v from 'valibot'
|
|
2135
|
+
|
|
2136
|
+
import { posts } from './schema'
|
|
2137
|
+
import * as schema from './schema'
|
|
2138
|
+
|
|
2139
|
+
export const backend = bunderstack({
|
|
2140
|
+
schema,
|
|
2141
|
+
database: { adapter: libsql(), url: 'file:./data.db' },
|
|
2142
|
+
access: {
|
|
2143
|
+
posts: {
|
|
2144
|
+
ownerColumn: 'userId',
|
|
2145
|
+
searchableColumns: ['title'],
|
|
2146
|
+
sortableColumns: ['createdAt', 'id'],
|
|
2147
|
+
},
|
|
2148
|
+
},
|
|
2149
|
+
realtime: true,
|
|
2150
|
+
api: (o) => ({
|
|
2151
|
+
postCount: o.public
|
|
2152
|
+
.input(v.optional(v.object({})))
|
|
2153
|
+
.handler(async ({ context }) => {
|
|
2154
|
+
const [row] = await context.db.select({ value: count() }).from(posts)
|
|
2155
|
+
return { value: row?.value ?? 0 }
|
|
2156
|
+
}),
|
|
2157
|
+
}),
|
|
2158
|
+
})
|
|
2159
|
+
|
|
2160
|
+
export const app = await backend.start()
|
|
2161
|
+
|
|
2162
|
+
export type App = typeof app
|
|
2163
|
+
|
|
2164
|
+
await provision(app)
|
|
2165
|
+
```
|
|
2166
|
+
|
|
2167
|
+
`provision(app)` pushes the schema while there is no migrations journal. Once
|
|
2168
|
+
you run `bunx drizzle-kit generate` and commit the migrations, the same call
|
|
2169
|
+
only applies pending migrations. Skip it when deployment manages migrations
|
|
2170
|
+
outside the application.
|
|
2171
|
+
|
|
2172
|
+
## Serve one handler
|
|
2173
|
+
|
|
2174
|
+
```ts
|
|
2175
|
+
// server.ts
|
|
2176
|
+
import { app } from './bunderstack'
|
|
2177
|
+
|
|
2178
|
+
Bun.serve({ fetch: app.handler })
|
|
2179
|
+
```
|
|
2180
|
+
|
|
2181
|
+
Generated procedures are callable through typed RPC and ordinary HTTP:
|
|
2182
|
+
|
|
2183
|
+
```text
|
|
2184
|
+
GET /api/posts
|
|
2185
|
+
POST /api/posts
|
|
2186
|
+
PATCH /api/posts/:id
|
|
2187
|
+
DELETE /api/posts/:id
|
|
2188
|
+
POST /api/rpc/postCount
|
|
2189
|
+
GET /api/openapi.json when openapi: true
|
|
2190
|
+
```
|
|
2191
|
+
|
|
2192
|
+
## Call it from the client
|
|
2193
|
+
|
|
2194
|
+
Only the `App` type crosses the boundary; server code is not bundled.
|
|
2195
|
+
|
|
2196
|
+
```ts
|
|
2197
|
+
// api-client.ts
|
|
2198
|
+
import { QueryClient } from '@tanstack/react-query'
|
|
2199
|
+
import { createClient } from 'bunderstack/query'
|
|
2200
|
+
import type { App } from './bunderstack'
|
|
2201
|
+
|
|
2202
|
+
export const queryClient = new QueryClient()
|
|
2203
|
+
export const api = createClient<App>({ queryClient })
|
|
2204
|
+
|
|
2205
|
+
const page = await api.posts.list.call({ limit: 20 })
|
|
2206
|
+
const total = await api.postCount.call({})
|
|
2207
|
+
// page and total are inferred from the server graph
|
|
2208
|
+
```
|
|
2209
|
+
|
|
2210
|
+
## Database choices
|
|
2211
|
+
|
|
2212
|
+
Import exactly one adapter. Its dialect must match your Drizzle tables.
|
|
2213
|
+
|
|
2214
|
+
| Database | Adapter | Optional peer |
|
|
2215
|
+
| ----------------- | ------------------------- | ---------------------- |
|
|
2216
|
+
| SQLite / Turso | `bunderstack/libsql` | `@libsql/client` |
|
|
2217
|
+
| Embedded Postgres | `bunderstack/pglite` | `@electric-sql/pglite` |
|
|
2218
|
+
| Postgres on Bun | `bunderstack/bun-sql` | none |
|
|
2219
|
+
| Postgres on Node | `bunderstack/postgres-js` | `postgres` |
|
|
2220
|
+
|
|
2221
|
+
Continue with [Auto CRUD](/docs/crud), then add application behavior in
|
|
2222
|
+
[API Procedures](/docs/api-procedures).
|
|
2223
|
+
|
|
2224
|
+
HTTP & WEBHOOKS
|
|
2225
|
+
|
|
2226
|
+
An oRPC procedure can be a typed RPC call and an ordinary HTTP endpoint at the
|
|
2227
|
+
same time. Use `.route()` for mobile clients, third-party integrations, and
|
|
2228
|
+
provider webhooks instead of mounting another router.
|
|
2229
|
+
|
|
2230
|
+
## Ordinary HTTP
|
|
2231
|
+
|
|
2232
|
+
```ts
|
|
2233
|
+
api: (o) => ({
|
|
2234
|
+
status: o.public
|
|
2235
|
+
.route({ method: 'GET', path: '/status' })
|
|
2236
|
+
.input(v.optional(v.object({})))
|
|
2237
|
+
.handler(() => ({ ok: true })),
|
|
2238
|
+
})
|
|
2239
|
+
```
|
|
2240
|
+
|
|
2241
|
+
The handler is available through `api.status.call({})` and `GET /status`.
|
|
2242
|
+
|
|
2243
|
+
## Signed webhook
|
|
2244
|
+
|
|
2245
|
+
Use `o.webhook` to signal that authentication comes from a provider signature.
|
|
2246
|
+
Detailed input exposes headers, query, parameters, and the decoded body while
|
|
2247
|
+
`context.getRawBody()` returns the exact bytes reserved before decoding.
|
|
2248
|
+
|
|
2249
|
+
```ts
|
|
2250
|
+
api: (o) => ({
|
|
2251
|
+
stripeWebhook: o.webhook
|
|
2252
|
+
.route({
|
|
2253
|
+
method: 'POST',
|
|
2254
|
+
path: '/webhooks/stripe',
|
|
2255
|
+
inputStructure: 'detailed',
|
|
2256
|
+
})
|
|
2257
|
+
.input(
|
|
2258
|
+
v.object({
|
|
2259
|
+
params: v.optional(v.object({}), {}),
|
|
2260
|
+
query: v.optional(v.record(v.string(), v.unknown()), {}),
|
|
2261
|
+
headers: v.record(v.string(), v.unknown()),
|
|
2262
|
+
body: v.record(v.string(), v.unknown()),
|
|
2263
|
+
}),
|
|
2264
|
+
)
|
|
2265
|
+
.handler(async ({ context, input }) => {
|
|
2266
|
+
const rawBody = await context.getRawBody()
|
|
2267
|
+
await verifyStripeSignature(
|
|
2268
|
+
rawBody,
|
|
2269
|
+
input.headers['stripe-signature'],
|
|
2270
|
+
context.env.STRIPE_WEBHOOK_SECRET,
|
|
2271
|
+
)
|
|
2272
|
+
await context.jobs.enqueue('processStripeEvent', input.body)
|
|
2273
|
+
return { received: true }
|
|
2274
|
+
}),
|
|
2275
|
+
})
|
|
2276
|
+
```
|
|
2277
|
+
|
|
2278
|
+
Session lookup stays lazy for public and webhook procedures, so signed provider
|
|
2279
|
+
requests do not pay for an unused database lookup.
|
|
2280
|
+
|
|
2281
|
+
## Response control
|
|
2282
|
+
|
|
2283
|
+
Detailed outputs can specify status and headers for routes that need them.
|
|
2284
|
+
Use `context.resHeaders` for response headers shared with the procedure
|
|
2285
|
+
contract. File downloads, redirects, and streams may return Web Standard
|
|
2286
|
+
`Response`, `Blob`, `File`, or `ReadableStream` values supported by oRPC.
|
|
2287
|
+
|
|
2288
|
+
## Rare protocol escape hatch
|
|
2289
|
+
|
|
2290
|
+
`app.handler` is a normal `(Request) => Promise<Response>` function. When a
|
|
2291
|
+
protocol truly cannot be expressed as an oRPC procedure, wrap it outside
|
|
2292
|
+
Bunderstack and delegate everything else:
|
|
2293
|
+
|
|
2294
|
+
```ts
|
|
2295
|
+
const fetch = (request: Request) => {
|
|
2296
|
+
if (new URL(request.url).pathname === '/special-protocol') {
|
|
2297
|
+
return handleSpecialProtocol(request)
|
|
2298
|
+
}
|
|
2299
|
+
return app.handler(request)
|
|
2300
|
+
}
|
|
2301
|
+
```
|
|
2302
|
+
|
|
2303
|
+
This keeps exceptional protocol code exceptional instead of adding a second
|
|
2304
|
+
router and error model to every application.
|
|
2305
|
+
|
|
2306
|
+
INTRODUCTION
|
|
2307
|
+
|
|
2308
|
+
Bunderstack is a batteries-included backend library for TypeScript on Bun. Give
|
|
2309
|
+
it a Drizzle schema and it builds one oRPC procedure graph containing generated
|
|
2310
|
+
CRUD, files, realtime, health checks, and your application procedures.
|
|
2311
|
+
|
|
2312
|
+
That graph is available in three useful forms:
|
|
2313
|
+
|
|
2314
|
+
- a fully inferred TypeScript client;
|
|
2315
|
+
- ordinary HTTP routes for browsers, mobile apps, and webhooks;
|
|
2316
|
+
- an optional OpenAPI document for external tooling.
|
|
2317
|
+
|
|
2318
|
+
RPC types are the primary contract. HTTP and OpenAPI are projections of the
|
|
2319
|
+
same procedures, not parallel implementations.
|
|
2320
|
+
|
|
2321
|
+
## One mental model
|
|
2322
|
+
|
|
2323
|
+
```text
|
|
2324
|
+
Drizzle schema + access rules
|
|
2325
|
+
│
|
|
2326
|
+
▼
|
|
2327
|
+
one oRPC procedure graph
|
|
2328
|
+
├── typed client
|
|
2329
|
+
├── ordinary HTTP
|
|
2330
|
+
└── realtime iterator
|
|
2331
|
+
```
|
|
2332
|
+
|
|
2333
|
+
`app.handler` is the single Web Standard `Request → Response` entry point.
|
|
2334
|
+
Better Auth owns `/api/auth/*`; oRPC owns generated and application routes.
|
|
2335
|
+
There is no general-purpose router to configure alongside them.
|
|
2336
|
+
|
|
2337
|
+
## Batteries, without a platform
|
|
2338
|
+
|
|
2339
|
+
Bunderstack includes database provisioning, Better Auth, access-controlled
|
|
2340
|
+
CRUD, file storage and transforms, email, validated environment variables,
|
|
2341
|
+
background jobs, rate limiting, idempotency, and realtime publication.
|
|
2342
|
+
|
|
2343
|
+
It remains a library inside your application. `app.db` is Drizzle,
|
|
2344
|
+
`app.auth` is Better Auth, and `app.storage`, `app.email`, `app.jobs`, and
|
|
2345
|
+
`app.realtime` are available in every procedure context. Your schema and data
|
|
2346
|
+
stay in your repository and infrastructure.
|
|
2347
|
+
|
|
2348
|
+
## Validation without lock-in
|
|
2349
|
+
|
|
2350
|
+
Every application validation slot accepts [Standard Schema](https://standardschema.dev/).
|
|
2351
|
+
The examples use Valibot because it is compact and tree-shakeable, but Zod,
|
|
2352
|
+
ArkType, and other Standard Schema implementations work too.
|
|
2353
|
+
|
|
2354
|
+
Start with [Getting Started](/docs/getting-started), then follow the primary
|
|
2355
|
+
path through [Auto CRUD](/docs/crud), [API Procedures](/docs/api-procedures),
|
|
2356
|
+
[Query Client](/docs/query-client), and [Sync & Realtime](/docs/sync-collections).
|
|
2357
|
+
|
|
2358
|
+
MIDDLEWARE
|
|
2359
|
+
|
|
2360
|
+
A middleware wraps a procedure call. It sees the request before the handler,
|
|
2361
|
+
can add to the context, and can observe or replace the result. Bunderstack has
|
|
2362
|
+
two places to put one, and the difference matters.
|
|
2363
|
+
|
|
2364
|
+
| Where | Reaches |
|
|
2365
|
+
| --------------------------------- | ----------------------------------------------------------- |
|
|
2366
|
+
| `.use()` on a base | Only procedures built from that base |
|
|
2367
|
+
| `middleware: [...]` in the config | Every procedure: generated CRUD, files, realtime, and yours |
|
|
2368
|
+
|
|
2369
|
+
## Per-base middleware
|
|
2370
|
+
|
|
2371
|
+
Use `.use()` when the middleware expresses a rule about a group of procedures —
|
|
2372
|
+
a role check, an organization scope, a quota. It belongs next to the base it
|
|
2373
|
+
guards. See [Extend a base](/docs/api-procedures#extend-a-base).
|
|
2374
|
+
|
|
2375
|
+
## Graph-wide middleware
|
|
2376
|
+
|
|
2377
|
+
Observability is the common case, and it is the one that per-base middleware
|
|
2378
|
+
gets wrong. Bunderstack builds the CRUD, storage, and realtime procedures
|
|
2379
|
+
itself, so they never pass through a base your application declares. A tracing
|
|
2380
|
+
middleware attached to `o.protected` covers your own procedures and leaves the
|
|
2381
|
+
generated CRUD — usually the larger share of traffic — unmeasured.
|
|
2382
|
+
|
|
2383
|
+
Register it in the config instead:
|
|
2384
|
+
|
|
2385
|
+
```ts
|
|
2386
|
+
// src/api/base.ts
|
|
2387
|
+
export const instrumentation = o.middleware(async ({ context, next, path }) => {
|
|
2388
|
+
const name = path.join('.')
|
|
2389
|
+
const startedAt = performance.now()
|
|
2390
|
+
try {
|
|
2391
|
+
const result = await next()
|
|
2392
|
+
metrics.record(name, performance.now() - startedAt, 'ok')
|
|
2393
|
+
return result
|
|
2394
|
+
} catch (error) {
|
|
2395
|
+
metrics.record(name, performance.now() - startedAt, 'error')
|
|
2396
|
+
throw error
|
|
2397
|
+
}
|
|
2398
|
+
})
|
|
2399
|
+
```
|
|
2400
|
+
|
|
2401
|
+
```ts
|
|
2402
|
+
const backend = bunderstack({
|
|
2403
|
+
schema,
|
|
2404
|
+
database,
|
|
2405
|
+
middleware: [instrumentation],
|
|
2406
|
+
api,
|
|
2407
|
+
})
|
|
2408
|
+
|
|
2409
|
+
const app = await backend.start()
|
|
2410
|
+
```
|
|
2411
|
+
|
|
2412
|
+
`o.middleware(...)` types the function over the request context, so you never
|
|
2413
|
+
write `os.$context<…>()` by hand. The middleware receives `path` — the
|
|
2414
|
+
procedure's path segments, such as `['boards', 'stats']` or `['todos',
|
|
2415
|
+
'list']` — plus `context`, `next`, and `errors`.
|
|
2416
|
+
|
|
2417
|
+
Middleware in the list runs outermost first, in array order.
|
|
2418
|
+
|
|
2419
|
+
## Reading the caller
|
|
2420
|
+
|
|
2421
|
+
A graph-wide middleware runs **before** authentication, so `context.user` does
|
|
2422
|
+
not exist there. Do not call `context.getSession()` to get it either: the
|
|
2423
|
+
session is resolved lazily on purpose, and forcing it makes every request pay
|
|
2424
|
+
for authentication — including signed webhooks that never needed it.
|
|
2425
|
+
|
|
2426
|
+
Use `context.peekSession()`. It returns the session that some later code
|
|
2427
|
+
already resolved, or `undefined`, and never starts a resolution:
|
|
2428
|
+
|
|
2429
|
+
```ts
|
|
2430
|
+
const result = await next()
|
|
2431
|
+
log({ path: path.join('.'), userId: context.peekSession()?.user?.id })
|
|
2432
|
+
return result
|
|
2433
|
+
```
|
|
2434
|
+
|
|
2435
|
+
Read it **after** `await next()`, when a protected procedure has resolved the
|
|
2436
|
+
session. Use it for observability only. Never use it for authorization — an
|
|
2437
|
+
unauthenticated request and an unresolved session look the same.
|
|
2438
|
+
|
|
2439
|
+
## Long-lived procedures
|
|
2440
|
+
|
|
2441
|
+
A realtime subscription is one procedure call that lives as long as the client
|
|
2442
|
+
stays connected. Code after `await next()` runs when the stream closes, not
|
|
2443
|
+
when the subscription starts. Filter those paths when that matters:
|
|
2444
|
+
|
|
2445
|
+
```ts
|
|
2446
|
+
export const instrumentation = o.middleware(async ({ next, path }) => {
|
|
2447
|
+
if (path[0] === 'realtime') return next()
|
|
2448
|
+
/* … */
|
|
2449
|
+
})
|
|
2450
|
+
```
|
|
2451
|
+
|
|
2452
|
+
## Adding to the context
|
|
2453
|
+
|
|
2454
|
+
`next({ context })` merges into the context, and the addition is typed for
|
|
2455
|
+
everything downstream:
|
|
2456
|
+
|
|
2457
|
+
```ts
|
|
2458
|
+
const withRequestId = o.middleware(async ({ context, next }) => {
|
|
2459
|
+
const requestId =
|
|
2460
|
+
context.request.headers.get('x-request-id') ?? crypto.randomUUID()
|
|
2461
|
+
return next({ context: { requestId } })
|
|
2462
|
+
})
|
|
2463
|
+
```
|
|
2464
|
+
|
|
2465
|
+
A graph-wide middleware that adds context extends it for generated procedures
|
|
2466
|
+
too, which do not read your fields. Keep additions cheap; anything expensive
|
|
2467
|
+
belongs on the base that actually needs it.
|
|
2468
|
+
|
|
2469
|
+
QUERY CLIENT
|
|
2470
|
+
|
|
2471
|
+
`bunderstack/query` derives generated CRUD and application procedures from
|
|
2472
|
+
`typeof app`. There is no code generation, route list, or duplicated response
|
|
2473
|
+
interface.
|
|
2474
|
+
|
|
2475
|
+
## Create the client
|
|
2476
|
+
|
|
2477
|
+
```bash
|
|
2478
|
+
bun add bunderstack @tanstack/react-query
|
|
2479
|
+
```
|
|
2480
|
+
|
|
2481
|
+
```ts
|
|
2482
|
+
// bunderstack.ts — server
|
|
2483
|
+
export const backend = bunderstack({ schema, access, api: (o) => ({}) })
|
|
2484
|
+
export const app = await backend.start()
|
|
2485
|
+
export type App = typeof app
|
|
2486
|
+
```
|
|
2487
|
+
|
|
2488
|
+
```ts
|
|
2489
|
+
// api-client.ts — client
|
|
2490
|
+
import { QueryClient } from '@tanstack/react-query'
|
|
2491
|
+
import { createClient } from 'bunderstack/query'
|
|
2492
|
+
import type { App } from './bunderstack'
|
|
2493
|
+
|
|
2494
|
+
export const queryClient = new QueryClient({
|
|
2495
|
+
defaultOptions: { queries: { staleTime: 30_000 } },
|
|
2496
|
+
})
|
|
2497
|
+
|
|
2498
|
+
export const api = createClient<App>({ queryClient })
|
|
2499
|
+
```
|
|
2500
|
+
|
|
2501
|
+
`App` is a type-only import, so server code and the Drizzle schema do not enter
|
|
2502
|
+
the browser bundle.
|
|
2503
|
+
|
|
2504
|
+
## Direct calls
|
|
2505
|
+
|
|
2506
|
+
Every procedure exposes `.call(input)`:
|
|
2507
|
+
|
|
2508
|
+
```ts
|
|
2509
|
+
const page = await api.posts.list.call({ limit: 20 })
|
|
2510
|
+
const post = await api.posts.get.call({ id: postId })
|
|
2511
|
+
const created = await api.posts.create.call({ title: 'Typed boundaries' })
|
|
2512
|
+
const stats = await api.stats.call({ boardId })
|
|
2513
|
+
```
|
|
2514
|
+
|
|
2515
|
+
Inputs and outputs are inferred from the unified oRPC graph. Generated table
|
|
2516
|
+
procedures and custom procedures use the same API shape.
|
|
2517
|
+
|
|
2518
|
+
## TanStack Query
|
|
2519
|
+
|
|
2520
|
+
Read procedures expose `.queryOptions()`. Write procedures expose
|
|
2521
|
+
`.mutationOptions()`.
|
|
2522
|
+
|
|
2523
|
+
```tsx
|
|
2524
|
+
import { useMutation, useQuery } from '@tanstack/react-query'
|
|
2525
|
+
|
|
2526
|
+
function Posts() {
|
|
2527
|
+
const posts = useQuery(api.posts.list.queryOptions({ input: { limit: 20 } }))
|
|
2528
|
+
const createPost = useMutation(api.posts.create.mutationOptions())
|
|
2529
|
+
|
|
2530
|
+
return (
|
|
2531
|
+
<button onClick={() => createPost.mutate({ title: 'Hello' })}>
|
|
2532
|
+
{posts.data?.items.length ?? 0} posts
|
|
2533
|
+
</button>
|
|
2534
|
+
)
|
|
2535
|
+
}
|
|
2536
|
+
```
|
|
2537
|
+
|
|
2538
|
+
Pass standard TanStack Query callbacks and options into the option factory:
|
|
2539
|
+
|
|
2540
|
+
```ts
|
|
2541
|
+
api.posts.create.mutationOptions({
|
|
2542
|
+
onSuccess: (created) => console.log(created.id),
|
|
2543
|
+
})
|
|
2544
|
+
```
|
|
2545
|
+
|
|
2546
|
+
## Loaders and prefetching
|
|
2547
|
+
|
|
2548
|
+
```ts
|
|
2549
|
+
export const Route = createFileRoute('/')({
|
|
2550
|
+
loader: () =>
|
|
2551
|
+
queryClient.ensureQueryData(
|
|
2552
|
+
api.posts.list.queryOptions({ input: { limit: 20 } }),
|
|
2553
|
+
),
|
|
2554
|
+
})
|
|
2555
|
+
```
|
|
2556
|
+
|
|
2557
|
+
## Files
|
|
2558
|
+
|
|
2559
|
+
Declared buckets receive small helpers in addition to their procedures:
|
|
2560
|
+
|
|
2561
|
+
```ts
|
|
2562
|
+
const uploaded = await api.files.images.upload(file)
|
|
2563
|
+
const thumbnail = api.files.images.url(uploaded.fileId, {
|
|
2564
|
+
w: 320,
|
|
2565
|
+
format: 'webp',
|
|
2566
|
+
})
|
|
2567
|
+
await api.files.images.delete(uploaded.fileId)
|
|
2568
|
+
```
|
|
2569
|
+
|
|
2570
|
+
## Realtime query caches
|
|
2571
|
+
|
|
2572
|
+
Use `syncRealtime` when the application uses TanStack Query without TanStack
|
|
2573
|
+
DB collections:
|
|
2574
|
+
|
|
2575
|
+
```ts
|
|
2576
|
+
import { syncRealtime } from 'bunderstack/query'
|
|
2577
|
+
|
|
2578
|
+
const connection = syncRealtime({
|
|
2579
|
+
api,
|
|
2580
|
+
queryClient,
|
|
2581
|
+
tables: ['posts', 'comments'],
|
|
2582
|
+
})
|
|
2583
|
+
|
|
2584
|
+
// call connection.close() when the application client is disposed
|
|
2585
|
+
```
|
|
2586
|
+
|
|
2587
|
+
The client applies typed events to matching caches and invalidates subscribed
|
|
2588
|
+
tables after reconnect. Connection retry, resume, and dead-stream detection are
|
|
2589
|
+
internal.
|
|
2590
|
+
|
|
2591
|
+
Changes reach the cache in one batch per animation frame, so a burst of writes
|
|
2592
|
+
costs one cache write and one invalidation per query key instead of one of each
|
|
2593
|
+
per event. Pass `notifyScheduler` to change that: `'sync'` writes as each event
|
|
2594
|
+
arrives, and a number debounces by that many milliseconds.
|
|
2595
|
+
|
|
2596
|
+
```ts
|
|
2597
|
+
syncRealtime({ api, queryClient, tables: ['posts'], notifyScheduler: 'sync' })
|
|
2598
|
+
```
|
|
2599
|
+
|
|
2600
|
+
Set `apply: 'patch'` to write changes into cached lists instead of invalidating
|
|
2601
|
+
them, so a write costs one request rather than two. A list is patched only when
|
|
2602
|
+
membership and ordering can be settled locally, and invalidated otherwise.
|
|
2603
|
+
|
|
2604
|
+
## Type utilities
|
|
2605
|
+
|
|
2606
|
+
```ts
|
|
2607
|
+
import type { InferSchema, InferSelect } from 'bunderstack/query'
|
|
2608
|
+
|
|
2609
|
+
type Schema = InferSchema<App>
|
|
2610
|
+
type Post = InferSelect<Schema['posts']>
|
|
2611
|
+
```
|
|
2612
|
+
|
|
2613
|
+
These aliases are useful at component boundaries. Avoid restating response
|
|
2614
|
+
types that the procedure client already knows.
|
|
2615
|
+
|
|
2616
|
+
STORAGE
|
|
2617
|
+
|
|
2618
|
+
## Upload
|
|
2619
|
+
|
|
2620
|
+
```bash
|
|
2621
|
+
curl -X POST /api/files -F "file=@photo.jpg"
|
|
2622
|
+
# 201 { fileId: "abc123.jpg", url: "/api/files/abc123.jpg" }
|
|
2623
|
+
```
|
|
2624
|
+
|
|
2625
|
+
Uploads require an authenticated session by default. File ownership is tracked in an internal metadata table.
|
|
2626
|
+
|
|
2627
|
+
## Retrieve / delete
|
|
2628
|
+
|
|
2629
|
+
```bash
|
|
2630
|
+
curl /api/files/abc123.jpg
|
|
2631
|
+
curl -X DELETE /api/files/abc123.jpg # 204 — owner only by default
|
|
2632
|
+
```
|
|
2633
|
+
|
|
2634
|
+
## Access rules
|
|
2635
|
+
|
|
2636
|
+
```ts
|
|
2637
|
+
bunderstack({
|
|
2638
|
+
schema,
|
|
2639
|
+
storage: {
|
|
2640
|
+
local: './uploads',
|
|
2641
|
+
defaultBucket: 'files',
|
|
2642
|
+
buckets: {
|
|
2643
|
+
files: {
|
|
2644
|
+
access: {
|
|
2645
|
+
create: 'authenticated', // default
|
|
2646
|
+
get: 'public', // default
|
|
2647
|
+
delete: 'owner', // default
|
|
2648
|
+
},
|
|
2649
|
+
},
|
|
2650
|
+
},
|
|
2651
|
+
},
|
|
2652
|
+
})
|
|
2653
|
+
```
|
|
2654
|
+
|
|
2655
|
+
## Local storage
|
|
2656
|
+
|
|
2657
|
+
```ts
|
|
2658
|
+
bunderstack({ schema, storage: { local: './uploads' } })
|
|
2659
|
+
```
|
|
2660
|
+
|
|
2661
|
+
## S3 / R2 / MinIO
|
|
2662
|
+
|
|
2663
|
+
```ts
|
|
2664
|
+
bunderstack({ schema, storage: { s3: true } })
|
|
2665
|
+
# Set S3_BUCKET, S3_REGION, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY in .env
|
|
2666
|
+
# For R2/MinIO also set S3_ENDPOINT
|
|
2667
|
+
```
|
|
2668
|
+
|
|
2669
|
+
```ts
|
|
2670
|
+
bunderstack({
|
|
2671
|
+
schema,
|
|
2672
|
+
storage: {
|
|
2673
|
+
local: './uploads',
|
|
2674
|
+
defaultBucket: 'files',
|
|
2675
|
+
buckets: {
|
|
2676
|
+
files: {
|
|
2677
|
+
upload: {
|
|
2678
|
+
accept: ['image/jpeg', 'image/png', 'image/webp'],
|
|
2679
|
+
maxSize: '5mb',
|
|
2680
|
+
},
|
|
2681
|
+
},
|
|
2682
|
+
},
|
|
2683
|
+
},
|
|
2684
|
+
})
|
|
2685
|
+
```
|
|
2686
|
+
|
|
2687
|
+
## Programmatic URLs (`app.storage.getUrl`)
|
|
2688
|
+
|
|
2689
|
+
Use `app.storage.getUrl` to programmatically resolve presigned S3 download URLs in production or local proxy URLs in development:
|
|
2690
|
+
|
|
2691
|
+
```ts
|
|
2692
|
+
// Programmatically get download URL for a file key
|
|
2693
|
+
const downloadUrl = await app.storage.getUrl('resumes/user_123/cv.pdf', {
|
|
2694
|
+
expiresIn: 3600,
|
|
2695
|
+
})
|
|
2696
|
+
```
|
|
2697
|
+
|
|
2698
|
+
## Server-side uploads (`app.storage.upload`)
|
|
2699
|
+
|
|
2700
|
+
Use `app.storage.upload` (or `context.storage.upload` in jobs and API procedures)
|
|
2701
|
+
to upload server-generated files such as PDFs and exports. It registers the
|
|
2702
|
+
storage metadata automatically, so the file is available through the generated
|
|
2703
|
+
download procedure and HTTP route:
|
|
2704
|
+
|
|
2705
|
+
```ts
|
|
2706
|
+
await app.storage.upload(
|
|
2707
|
+
'adaptations/123/resume.pdf',
|
|
2708
|
+
pdfBytes,
|
|
2709
|
+
'application/pdf',
|
|
2710
|
+
{ filename: 'resume.pdf', ownerId: user.id },
|
|
2711
|
+
)
|
|
2712
|
+
```
|
|
2713
|
+
|
|
2714
|
+
Nested keys (like `adaptations/123/resume.pdf`) are fully supported by `GET /api/files/:bucket/*` and `DELETE /api/files/:bucket/*`.
|
|
2715
|
+
|
|
2716
|
+
SYNC & REALTIME
|
|
2717
|
+
|
|
2718
|
+
`bunderstack/sync` layers TanStack DB collections over the same generated oRPC
|
|
2719
|
+
procedures. It provides local live queries, optimistic mutations, scoped
|
|
2720
|
+
windows, and reliable realtime updates inferred from your server app.
|
|
2721
|
+
|
|
2722
|
+
## Create a sync client
|
|
2723
|
+
|
|
2724
|
+
```bash
|
|
2725
|
+
bun add bunderstack @tanstack/react-query @tanstack/db @tanstack/query-db-collection
|
|
2726
|
+
```
|
|
2727
|
+
|
|
2728
|
+
```ts
|
|
2729
|
+
import { createSyncClient } from 'bunderstack/sync'
|
|
2730
|
+
import type { App } from './bunderstack'
|
|
2731
|
+
|
|
2732
|
+
const api = createSyncClient<App>({ queryClient })
|
|
2733
|
+
```
|
|
2734
|
+
|
|
2735
|
+
Each exposed table receives:
|
|
2736
|
+
|
|
2737
|
+
```ts
|
|
2738
|
+
api.posts.collection
|
|
2739
|
+
api.posts.table
|
|
2740
|
+
api.posts.scopedCollection(options)
|
|
2741
|
+
api.posts.collectionByIds(ids)
|
|
2742
|
+
```
|
|
2743
|
+
|
|
2744
|
+
Collections are stable by configuration. Calling `scopedCollection()` or
|
|
2745
|
+
`collectionByIds()` with equivalent options returns the existing collection;
|
|
2746
|
+
do not cast a plain array into a collection and do not rebuild collections on
|
|
2747
|
+
every render.
|
|
2748
|
+
|
|
2749
|
+
## Optimistic mutations and reconciliation
|
|
2750
|
+
|
|
2751
|
+
```ts
|
|
2752
|
+
api.posts.collection.insert({ id, title, userId })
|
|
2753
|
+
api.posts.collection.update(id, (draft) => {
|
|
2754
|
+
draft.title = 'renamed'
|
|
2755
|
+
})
|
|
2756
|
+
api.posts.collection.delete(id)
|
|
2757
|
+
```
|
|
2758
|
+
|
|
2759
|
+
The server mutation returns the canonical row after defaults, ownership, and
|
|
2760
|
+
database transforms are applied. The sync layer reconciles that row into every
|
|
2761
|
+
materialized view without a follow-up list request. Several overlapping local
|
|
2762
|
+
edits to the same row are coalesced so a stale completion cannot overwrite a
|
|
2763
|
+
newer optimistic state.
|
|
2764
|
+
|
|
2765
|
+
## Scoped collections
|
|
2766
|
+
|
|
2767
|
+
Use a growing window for feeds and other ordered datasets:
|
|
2768
|
+
|
|
2769
|
+
```ts
|
|
2770
|
+
const feed = api.posts.scopedCollection({
|
|
2771
|
+
filters: { replyToId: null },
|
|
2772
|
+
sort: 'createdAt',
|
|
2773
|
+
order: 'desc',
|
|
2774
|
+
initialCount: 20,
|
|
2775
|
+
})
|
|
2776
|
+
|
|
2777
|
+
const result = useLiveQuery((q) =>
|
|
2778
|
+
q
|
|
2779
|
+
.from({ post: feed.collection })
|
|
2780
|
+
.orderBy(({ post }) => post.createdAt, 'desc'),
|
|
2781
|
+
)
|
|
2782
|
+
|
|
2783
|
+
await feed.loadMore()
|
|
2784
|
+
feed.hasMore()
|
|
2785
|
+
```
|
|
2786
|
+
|
|
2787
|
+
Use `collectionByIds(ids)` for exact relation lookups rather than searching a
|
|
2788
|
+
capped base collection:
|
|
2789
|
+
|
|
2790
|
+
```ts
|
|
2791
|
+
const authorIds = [...new Set(posts.map((post) => post.userId))].sort()
|
|
2792
|
+
const authors = api.user.collectionByIds(authorIds)
|
|
2793
|
+
```
|
|
2794
|
+
|
|
2795
|
+
## One realtime path
|
|
2796
|
+
|
|
2797
|
+
On the server, generated writes and `context.realtime.publish()` emit typed
|
|
2798
|
+
changes through oRPC Publisher. The built-in `realtime.changes` procedure
|
|
2799
|
+
returns an event iterator. On the client, Bunderstack applies each change to
|
|
2800
|
+
query caches and all materialized collection views.
|
|
2801
|
+
|
|
2802
|
+
```text
|
|
2803
|
+
database write
|
|
2804
|
+
→ oRPC Publisher
|
|
2805
|
+
→ realtime.changes iterator
|
|
2806
|
+
→ query cache + TanStack DB collections
|
|
2807
|
+
```
|
|
2808
|
+
|
|
2809
|
+
Realtime starts automatically in browser sync clients and stays disabled
|
|
2810
|
+
during SSR. Applications do not poll a list endpoint and do not create a second
|
|
2811
|
+
event client.
|
|
2812
|
+
|
|
2813
|
+
## Reliability behavior
|
|
2814
|
+
|
|
2815
|
+
The library owns the transport lifecycle:
|
|
2816
|
+
|
|
2817
|
+
- an internal heartbeat keeps quiet streams observable without producing
|
|
2818
|
+
application events or cache work;
|
|
2819
|
+
- a stream that stops delivering is detected and replaced. The server sends a
|
|
2820
|
+
heartbeat on a fixed interval and advertises that interval on the event
|
|
2821
|
+
itself. The client tears the connection down after 2.5 intervals of silence
|
|
2822
|
+
and reconnects. Without this a connection killed by a proxy idle-timeout, a
|
|
2823
|
+
sleeping laptop, or a network change would hang open and deliver nothing;
|
|
2824
|
+
- failed connections retry with exponential backoff and jitter rather than a
|
|
2825
|
+
fixed request loop;
|
|
2826
|
+
- Publisher event IDs allow resume while retained events are available;
|
|
2827
|
+
- after reconnect, subscribed tables are refetched once so an expired replay
|
|
2828
|
+
window cannot leave the cache stale;
|
|
2829
|
+
- changes are applied in one batch per animation frame, so a burst of writes
|
|
2830
|
+
costs one cache write and one invalidation per query key rather than one of
|
|
2831
|
+
each per event;
|
|
2832
|
+
- a heartbeat is filtered inside the transport and never appears in a live
|
|
2833
|
+
query.
|
|
2834
|
+
|
|
2835
|
+
Publisher replay is an optimization. The database and the reconnect refetch
|
|
2836
|
+
remain the source of cache correctness.
|
|
2837
|
+
|
|
2838
|
+
## Custom publications
|
|
2839
|
+
|
|
2840
|
+
Publish a canonical row from a procedure or job when a write happens outside
|
|
2841
|
+
generated CRUD:
|
|
2842
|
+
|
|
2843
|
+
```ts
|
|
2844
|
+
await context.realtime.publish(schema.posts, 'update', updatedPost)
|
|
2845
|
+
```
|
|
2846
|
+
|
|
2847
|
+
Use Redis realtime configuration when web and worker processes are separate;
|
|
2848
|
+
the memory publisher is process-local.
|
|
2849
|
+
|
|
2850
|
+
For TanStack Query without collections, use `syncRealtime` from
|
|
2851
|
+
`bunderstack/query`. Framework adapters are covered in
|
|
2852
|
+
[Framework Portability](/docs/framework-portability).
|
|
2853
|
+
|
|
2854
|
+
TEMPLATES & AGENT SKILLS
|
|
2855
|
+
|
|
2856
|
+
Bunderstack ships with a production-ready SaaS template and a suite of AI coding agent skills to streamline building full-stack applications with TanStack Start, TanStack Router, and TanStack Query.
|
|
2857
|
+
|
|
2858
|
+
---
|
|
2859
|
+
|
|
2860
|
+
## BunderSaaS Template (`templates/tanstack-start-saas`)
|
|
2861
|
+
|
|
2862
|
+
The **BunderSaaS** template is a complete, full-stack SaaS workspace built on **Bunderstack** and **TanStack Start**.
|
|
2863
|
+
|
|
2864
|
+
### Key Features
|
|
2865
|
+
|
|
2866
|
+
- **Dual Dashboards & Auth Contexts**:
|
|
2867
|
+
- **Client Workspace (`/app/*`)**: Guarded by `clientAuth` route context in `src/routes/app/layout.tsx` using `requireClientAuth`.
|
|
2868
|
+
- **Admin Portal (`/admin/*`)**: Guarded by `adminAuth` route context in `src/routes/admin/layout.tsx` using `requireAdminAuth` (`role === 'admin'`).
|
|
2869
|
+
- **Isomorphic Session Validation**: Root `beforeLoad` in `src/routes/__root.tsx` fetches the session user isomorphically via `fetchUser()`.
|
|
2870
|
+
- **shadcn/ui Ready**: Pre-configured `components.json`, Tailwind v4, Lucide icons, Radix primitives, and `@shadcnstore` registry support (`bunx shadcn@latest add <component>`).
|
|
2871
|
+
- **Real-Time Delivery Rail**: Visual project status and proof attachment upload pipeline.
|
|
2872
|
+
- **Background Worker & Cron**: Wired `worker.ts` process for background jobs and scheduled sweeps.
|
|
2873
|
+
|
|
2874
|
+
### Quick Start with BunderSaaS
|
|
2875
|
+
|
|
2876
|
+
```bash
|
|
2877
|
+
cd templates/tanstack-start-saas
|
|
2878
|
+
bun install
|
|
2879
|
+
cp .env.example .env
|
|
2880
|
+
bun run db:generate
|
|
2881
|
+
bun run dev
|
|
2882
|
+
```
|
|
2883
|
+
|
|
2884
|
+
In a separate terminal, run the background queue worker:
|
|
2885
|
+
|
|
2886
|
+
```bash
|
|
2887
|
+
bun run worker
|
|
2888
|
+
```
|
|
2889
|
+
|
|
2890
|
+
---
|
|
2891
|
+
|
|
2892
|
+
## Bunderstack Agent Skills
|
|
2893
|
+
|
|
2894
|
+
Two skills ship with the `bunderstack` package, so the guidance an agent reads
|
|
2895
|
+
always matches the version you installed.
|
|
2896
|
+
|
|
2897
|
+
| Skill | Use it for |
|
|
2898
|
+
| --------------------------- | -------------------------------------------------------------------------------------------------- |
|
|
2899
|
+
| `creating-bunderstack-apps` | Structuring an app; declaring procedures, bases, middleware, access rules, jobs, storage, realtime |
|
|
2900
|
+
| `migrating-to-bunderstack` | Replacing separate auth, database, API, storage, email, jobs, cron, or realtime infrastructure |
|
|
2901
|
+
|
|
2902
|
+
### Install them
|
|
2903
|
+
|
|
2904
|
+
```bash
|
|
2905
|
+
bunx bunderstack skills
|
|
2906
|
+
```
|
|
2907
|
+
|
|
2908
|
+
That copies both skills into `.agents/skills/` and adds a Bunderstack block to
|
|
2909
|
+
`AGENTS.md`. The pointer matters as much as the files: agents read `AGENTS.md`
|
|
2910
|
+
before they search for anything, so it is what actually pulls the skills into
|
|
2911
|
+
context when the model works in your repository.
|
|
2912
|
+
|
|
2913
|
+
Re-run the command after upgrading Bunderstack to pick up the current guidance.
|
|
2914
|
+
It replaces its own block and leaves the rest of `AGENTS.md` alone.
|
|
2915
|
+
|
|
2916
|
+
Keep them honest in CI:
|
|
2917
|
+
|
|
2918
|
+
```bash
|
|
2919
|
+
bunx bunderstack skills --check
|
|
2920
|
+
```
|
|
2921
|
+
|
|
2922
|
+
That exits non-zero when the installed skills drift from the package or the
|
|
2923
|
+
`AGENTS.md` block is missing. Use `--dir` to install somewhere other than
|
|
2924
|
+
`.agents/skills`.
|
|
2925
|
+
|
|
2926
|
+
The package ships two agent-readable references. `llms.txt` is the compact
|
|
2927
|
+
working contract at `node_modules/bunderstack/llms.txt` and
|
|
2928
|
+
[/llms.txt](/llms.txt). `llms-full.txt` contains the complete documentation
|
|
2929
|
+
corpus at `node_modules/bunderstack/llms-full.txt` and
|
|
2930
|
+
[/llms-full.txt](/llms-full.txt). Start compact and load the full corpus when a
|
|
2931
|
+
task crosses several framework capabilities.
|
|
2932
|
+
|
|
2933
|
+
## TanStack Agent Skills
|
|
2934
|
+
|
|
2935
|
+
Bunderstack integrates [TanStack Agent Skills](https://github.com/DeckardGer/tanstack-agent-skills) to guide AI coding assistants (Antigravity, Cursor, Claude Code, etc.) in following best practices when generating or modifying code.
|
|
2936
|
+
|
|
2937
|
+
### Available Skills
|
|
2938
|
+
|
|
2939
|
+
1. **`tanstack-start-best-practices`**:
|
|
2940
|
+
- Server functions (`createServerFn`), Standard Schema input validation, isomorphic session checks, SSR hydration safety.
|
|
2941
|
+
2. **`tanstack-router-best-practices`**:
|
|
2942
|
+
- Root context typing (`createRootRouteWithContext`), `beforeLoad` route guards, search parameter validation (`validateSearch`), and `notFoundComponent` / `errorComponent` handling.
|
|
2943
|
+
3. **`tanstack-query-best-practices`**:
|
|
2944
|
+
- `staleTime` optimization, query key factory patterns, and optimistic UI mutations.
|
|
2945
|
+
4. **`tanstack-integration-best-practices`**:
|
|
2946
|
+
- Seamless data flow coordination between TanStack Router, Query, and Start.
|
|
2947
|
+
|
|
2948
|
+
### Installing Skills in Your Workspace
|
|
2949
|
+
|
|
2950
|
+
To add the TanStack Agent Skills to your project workspace:
|
|
2951
|
+
|
|
2952
|
+
```bash
|
|
2953
|
+
npx -y skills add https://github.com/deckardger/tanstack-agent-skills
|
|
2954
|
+
```
|
|
2955
|
+
|
|
2956
|
+
These are third-party skills pulled from GitHub, so they are versioned
|
|
2957
|
+
independently of Bunderstack.
|
|
2958
|
+
|
|
2959
|
+
THUMBNAILS
|
|
2960
|
+
|
|
2961
|
+
Append transform params to any image URL. First request generates and caches; repeat requests hit the cache.
|
|
2962
|
+
|
|
2963
|
+
## Query parameters
|
|
2964
|
+
|
|
2965
|
+
| Param | Values | Description |
|
|
2966
|
+
| --------- | ------------------------------------------------------- | ------------------- |
|
|
2967
|
+
| `w` | integer | Width in pixels |
|
|
2968
|
+
| `h` | integer | Height in pixels |
|
|
2969
|
+
| `fit` | `cover` \| `contain` \| `fill` \| `inside` \| `outside` | Resize strategy |
|
|
2970
|
+
| `format` | `webp` \| `jpeg` \| `png` \| `avif` | Output format |
|
|
2971
|
+
| `quality` | 1–100 | Compression quality |
|
|
2972
|
+
|
|
2973
|
+
## Examples
|
|
2974
|
+
|
|
2975
|
+
```bash
|
|
2976
|
+
/files/photo.jpg?w=200&h=200&format=webp
|
|
2977
|
+
/files/photo.jpg?w=400&fit=contain
|
|
2978
|
+
/files/photo.jpg?format=avif&quality=80
|
|
2979
|
+
```
|