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.
Files changed (56) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/dist/api/catalog.d.ts +8 -0
  3. package/dist/api/catalog.d.ts.map +1 -0
  4. package/dist/api/catalog.js +56 -0
  5. package/dist/api/catalog.js.map +1 -0
  6. package/dist/api/registry.d.ts.map +1 -1
  7. package/dist/api/registry.js +1 -0
  8. package/dist/api/registry.js.map +1 -1
  9. package/dist/api/router.d.ts +3 -0
  10. package/dist/api/router.d.ts.map +1 -1
  11. package/dist/api/router.js +7 -2
  12. package/dist/api/router.js.map +1 -1
  13. package/dist/backend.d.ts.map +1 -1
  14. package/dist/backend.js +9 -1
  15. package/dist/backend.js.map +1 -1
  16. package/dist/blueprint.d.ts +21 -1
  17. package/dist/blueprint.d.ts.map +1 -1
  18. package/dist/blueprint.js +45 -17
  19. package/dist/blueprint.js.map +1 -1
  20. package/dist/env.d.ts +18 -0
  21. package/dist/env.d.ts.map +1 -1
  22. package/dist/env.js +1 -0
  23. package/dist/env.js.map +1 -1
  24. package/dist/index.d.ts +3 -1
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +1 -0
  27. package/dist/index.js.map +1 -1
  28. package/dist/jobs/index.d.ts +1 -1
  29. package/dist/jobs/index.d.ts.map +1 -1
  30. package/dist/jobs/index.js.map +1 -1
  31. package/dist/jobs/runtime.d.ts +5 -1
  32. package/dist/jobs/runtime.d.ts.map +1 -1
  33. package/dist/jobs/runtime.js +15 -5
  34. package/dist/jobs/runtime.js.map +1 -1
  35. package/dist/jobs/worker.d.ts +6 -0
  36. package/dist/jobs/worker.d.ts.map +1 -1
  37. package/dist/jobs/worker.js +77 -21
  38. package/dist/jobs/worker.js.map +1 -1
  39. package/dist/manifest.d.ts +20 -0
  40. package/dist/manifest.d.ts.map +1 -1
  41. package/dist/manifest.js +68 -6
  42. package/dist/manifest.js.map +1 -1
  43. package/dist/readiness-probes.d.ts +4 -0
  44. package/dist/readiness-probes.d.ts.map +1 -0
  45. package/dist/readiness-probes.js +23 -0
  46. package/dist/readiness-probes.js.map +1 -0
  47. package/dist/readiness.d.ts +39 -0
  48. package/dist/readiness.d.ts.map +1 -0
  49. package/dist/readiness.js +72 -0
  50. package/dist/readiness.js.map +1 -0
  51. package/dist/runtime.d.ts +1 -1
  52. package/dist/runtime.d.ts.map +1 -1
  53. package/dist/runtime.js +15 -5
  54. package/dist/runtime.js.map +1 -1
  55. package/llms-full.txt +2979 -0
  56. 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
+ ```