bunderstack 0.23.1 → 0.23.4

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.
@@ -1,85 +1,558 @@
1
1
  ---
2
2
  name: migrating-to-bunderstack
3
- description: Use when moving an existing or partially migrated application onto Bunderstack and replacing its separate auth, database, API, storage, email, jobs, cron, or realtime infrastructure, or when finishing a migration that stalled part-way.
3
+ description: Use when building, structuring, or migrating an application on Bunderstack, configuring Drizzle schemas, Better Auth, oRPC procedures, access rules, storage, background jobs, realtime, or preparing deployments for Bunderhost.
4
4
  ---
5
5
 
6
- # Migrating to Bunderstack
7
-
8
- Migration deletes infrastructure rather than wrapping it, and the application
9
- stays working at every phase. For a new application with no existing
10
- infrastructure, use `creating-bunderstack-apps` instead.
11
-
12
- ## Workflow
13
-
14
- 1. Inventory current auth, database, API, storage, email, jobs, cron, realtime,
15
- env, migrations, and deployment ownership. Record which module owns each
16
- capability today and which call sites depend on it.
17
- 2. Add a migration contract test before removing legacy paths, so every
18
- deletion has a gate that fails when behaviour is lost.
19
- 3. Establish one Bunderstack backend declaration, one runtime, and one schema aggregate.
20
- 4. Move auth and access without creating duplicate instances.
21
- 5. Replace infrastructure capability by capability.
22
- 6. Mount one handler and separate the production worker.
23
- 7. Remove wrappers only after call sites and tests move.
24
- 8. Generate migrations and the blueprint, then verify production topology.
25
-
26
- ## One live instance per capability
27
-
28
- The most damaging migration state is two working implementations of the same
29
- capability. A second Better Auth instance splits session validation. A second
30
- database client sits outside provisioning, migration state, and request
31
- transactions. A second env schema drifts from the validated one and passes
32
- locally while failing at boot.
33
-
34
- Pass `authConfig` into `bunderstack()`, start that declaration once in the web
35
- entry, and re-export `app.auth` and `app.db` from the runtime entry. Let the
36
- declared `env` schema plus the source passed to `backend.start()` be the only
37
- validated source. A more specific file route also shadows the catch-all, so a
38
- surviving `/api/auth/$` silently keeps serving the instance you meant to delete.
39
-
40
- ## Replacements
41
-
42
- | Legacy shape | Current contract |
43
- | ------------------------------------------------------------- | --------------------------------------------------------------------------------- |
44
- | Hand-written `ALL: ({ request }) => app.handler(request)` map | `createApiHandlers(app)` on one `/api/$` route |
45
- | Separate `/api/auth/$` or `/api/trpc/$` mounts | Deleted; the catch-all serves Better Auth and the unified oRPC `/api/rpc/*` graph |
46
- | tRPC router and procedure clients | Router modules over `defineApi` bases, passed as `api`, with one inferred client |
47
- | Per-file router factories taking a bag of procedures | Bases exported from one module, imported by plain router objects |
48
- | Hand-built `ORPCError` or a second error model | `errors.CODE({ message })`, or `BunderstackError` outside a handler |
49
- | Tracing attached to an application base | `middleware: [...]`, which also covers the generated CRUD |
50
- | Hand-rolled limit/offset/count blocks | `listSpec(table, options)` applied to your own base |
51
- | `any`-typed db parameters in helpers | `BunderstackDb<typeof schema>` and `BunderstackTx<typeof schema>` |
52
- | Worker started from the web entry | `src/worker.ts` owning `app.runWorker()` |
53
- | `/api/cron/*` guarded by a shared secret | `jobs.cron()` with platform delivery |
54
- | Channel-and-payload realtime publishing | `ctx.realtime.publish(schema.tasks, 'update', row)` after the write commits |
55
- | AWS or Tigris SDK wrapper | `app.storage` buckets |
56
- | Resend SDK wrapper | `app.email.send(...)` |
57
- | `createEnv()` beside the app | `env` schema in `bunderstack()` and source in `backend.start()` |
58
- | Implicit database driver | Explicit adapter, `database: { adapter: libsql(), url }` |
59
- | Schema push in production | Committed Drizzle `migrations/`, applied by `provision(app)` |
60
- | Undeclared deployment | `package.json#bunderstack.entry` and a checked blueprint |
61
-
62
- Read [runtime replacements](references/runtime-replacements.md) before writing
63
- any replacement above; it holds the current snippets and the realtime transport
64
- rule for multi-process deployments. Read the
65
- [audit checklist](references/audit-checklist.md) during phase 1 to inventory
66
- ownership, and again at phase 7 before each deletion.
67
-
68
- ## Deletion gate
69
-
70
- Do not delete a legacy module until its replacement is mounted, every call site
71
- imports the replacement, a migration contract test covers the behaviour, and
72
- `bun run typecheck` reports no remaining importers. A one-release re-export
73
- shim is acceptable when call sites are numerous; the shim is deleted under this
74
- same gate. Uninstall the replaced SDK package in the commit that removes its
75
- last importer, so a stale wrapper cannot be reintroduced silently.
76
-
77
- Tests should use lexically scoped `await using` fixtures from `backend.test()`;
78
- scripts that explicitly start a runtime must close the runtime they own.
79
-
80
- ## Production gate
81
-
82
- Before the cutover deploy: committed migrations exist, the worker runs as its
83
- own process, web and worker share a realtime transport if jobs publish events,
84
- `package.json#bunderstack.entry` points at the entry, and
85
- `bun run blueprint:check` passes in CI.
6
+ # Bunderstack Architecture & Migration Guide
7
+
8
+ ## Overview
9
+
10
+ Bunderstack is a batteries-included full-stack backend framework for Bun unifying:
11
+ - **Drizzle ORM** (libSQL / SQLite / Postgres)
12
+ - **Better Auth** (authentication & session management)
13
+ - **oRPC v2** (unified type-safe RPC & OpenAPI/REST procedures with Standard Schema / Valibot)
14
+ - **Storage** (S3 / local disk buckets with transforms via Bun.Image)
15
+ - **Background Jobs & Cron** (durable queue, schedule execution, retry handling)
16
+ - **Realtime** (SSE streaming with heartbeat recovery & optimistic correlation)
17
+ - **Email** (Resend / SMTP / Console)
18
+
19
+ ---
20
+
21
+ ## 1. Core Architectural Model
22
+
23
+ ### Declaration vs. Runtime Separation
24
+
25
+ Bunderstack separates the static application declaration from the running instance:
26
+
27
+ ```ts
28
+ import { bunderstack } from 'bunderstack'
29
+ import { libsql } from 'bunderstack/libsql'
30
+ import { schema } from './schema'
31
+ import { access } from './access'
32
+ import { api } from './api'
33
+
34
+ // 1. Pure synchronous declaration (does NO I/O, no DB connection, exports static manifest)
35
+ export const backend = bunderstack({
36
+ schema,
37
+ access,
38
+ database: { adapter: libsql(), url: process.env.DATABASE_URL ?? 'file:./data.db' },
39
+ api,
40
+ })
41
+
42
+ // 2. Explicit runtime start (connects to DB, migrates, starts services)
43
+ export const app = await backend.start()
44
+ export type App = typeof app
45
+ ```
46
+
47
+ - `backend.manifest` can be read statically by tools (such as blueprint generators) without starting the app or connecting to a database.
48
+ - `app = await backend.start()` explicitly boots the runtime.
49
+ - `backend.test()` creates an isolated, lexically owned test fixture.
50
+
51
+ ### Single Package & Flat Subpath Exports
52
+
53
+ All Bunderstack capabilities are imported directly from single-segment subpaths of `bunderstack`:
54
+
55
+ | Subpath Import | Purpose |
56
+ | --- | --- |
57
+ | `bunderstack` | Core backend builder (`bunderstack`, `defineApi`, `defineAccess`, `BunderstackError`) |
58
+ | `bunderstack/libsql` | libSQL / SQLite database adapter |
59
+ | `bunderstack/postgres-js` | postgres.js database adapter |
60
+ | `bunderstack/bun-sql` | `Bun.sql` Postgres adapter |
61
+ | `bunderstack/pglite` | PGlite in-memory / embedded Postgres adapter |
62
+ | `bunderstack/client` | Framework-neutral typed client & `createLiveView` |
63
+ | `bunderstack/client-react` | React LiveView hook (`useLiveView`) |
64
+ | `bunderstack/client-rest` | Type-safe REST client |
65
+ | `bunderstack/query` | TanStack Query integration (`createClient`, `syncRealtime`) |
66
+ | `bunderstack/query-react` | React-specific query helpers |
67
+ | `bunderstack/sync` | TanStack DB realtime sync collections |
68
+ | `bunderstack/start` | TanStack Start integration (`createApiHandlers`) |
69
+ | `bunderstack/start-auth` | Better Auth client for TanStack Start |
70
+ | `bunderstack/provision` | Database schema provisioning (`provision(app)`) |
71
+ | `bunderstack/testing` | Test fixture helpers |
72
+ | `bunderstack/schema` | Internal system tables (`export * from 'bunderstack/schema'`) |
73
+ | `bunderstack/typeid` | TypeID column types & generators |
74
+
75
+ ---
76
+
77
+ ## 2. Project Structuring & Best Practices
78
+
79
+ ### Scale Decision: Flat vs. Modular
80
+
81
+ 1. **Flat Layout (MVP / Small Service: < 5 tables, < 5 procedures, 1 job):**
82
+ ```
83
+ src/
84
+ ├── bunderstack.ts # backend declaration & app start
85
+ ├── schema.ts # Drizzle schema
86
+ ├── access.ts # defineAccess rules
87
+ ├── api.ts # defineApi and procedures
88
+ ├── env.ts # Valibot envSchema
89
+ └── worker.ts # app.runWorker() entry
90
+ ```
91
+
92
+ 2. **Modular Layout (Production SaaS / Multi-Domain, HR Breakers pattern):**
93
+ Consolidate all backend logic in `src/bunderstack/` with domain separation:
94
+ ```
95
+ src/bunderstack/
96
+ ├── backend.ts # Synchronous bunderstack({...}) declaration
97
+ ├── index.ts # export const app = await backend.start(), provision(app), exports { db, auth, env }
98
+ ├── env.ts # envSchema (server / client) via Valibot
99
+ ├── access.ts # defineAccess(schema, { ... })
100
+ ├── auth.ts # authConfig for Better Auth
101
+ ├── schema/ # Segmented Drizzle tables
102
+ │ ├── auth.ts # Better Auth tables
103
+ │ ├── billing.ts # Billing / subscription tables
104
+ │ ├── core.ts # Domain entities
105
+ │ └── index.ts # Aggregates schemas + export * from 'bunderstack/schema'
106
+ ├── api/ # oRPC Procedure Graph
107
+ │ ├── base.ts # defineApi({ schema, env }), bases (public, protected, admin), middleware
108
+ │ ├── billing.ts # Billing domain router (plain object)
109
+ │ ├── users.ts # Users domain router
110
+ │ ├── admin.ts # Admin router
111
+ │ └── index.ts # export const api = { billing, users, admin }
112
+ ├── jobs/ # Background Jobs & Cron
113
+ │ ├── generate-pdf.ts# Heavy job handler + onFailed
114
+ │ ├── cleanup.ts # jobs.cron() handler
115
+ │ └── index.ts # jobs.define({ ... })
116
+ ├── methods.ts # (or services/) Pure domain database operations and business logic
117
+ └── types.ts # Database type aliases ($inferSelect, $inferInsert)
118
+ ```
119
+
120
+ ### Rule: Thin Routers vs. Service Layer (`methods.ts`)
121
+
122
+ Keep API procedures thin. Routers only validate input, authorize, and delegate heavy logic to service functions:
123
+
124
+ ```ts
125
+ // src/bunderstack/api/resumes.ts
126
+ import * as v from 'valibot'
127
+ import { protectedProcedure } from './base'
128
+ import { generateResumeForUser } from '../methods'
129
+
130
+ export const resumesRouter = {
131
+ generate: protectedProcedure
132
+ .input(v.object({ templateId: v.string() }))
133
+ .output(v.object({ jobId: v.string() }))
134
+ .handler(async ({ context, input }) => {
135
+ // Delegate to service function in methods.ts
136
+ const jobId = await generateResumeForUser(context, input.templateId)
137
+ return { jobId }
138
+ }),
139
+ }
140
+ ```
141
+
142
+ ### Rule: Module-Scoped API Builder (`api/base.ts`)
143
+
144
+ Declare `defineApi` once at module scope. Router files import procedure bases directly without needing factory functions:
145
+
146
+ ```ts
147
+ // src/bunderstack/api/base.ts
148
+ import { defineApi } from 'bunderstack'
149
+ import { envSchema } from '../env'
150
+ import { schema } from '../schema'
151
+ import { eq } from 'drizzle-orm'
152
+
153
+ export const o = defineApi({ schema, env: envSchema })
154
+
155
+ export const publicProcedure = o.public
156
+
157
+ export const protectedProcedure = o.protected.use(async ({ context, next }) => {
158
+ // context.user is guaranteed non-null in o.protected
159
+ return next()
160
+ })
161
+
162
+ export const adminProcedure = protectedProcedure.use(async ({ context, next, errors }) => {
163
+ if (context.user.role !== 'admin') {
164
+ throw errors.FORBIDDEN({ message: 'Admin privileges required' })
165
+ }
166
+ return next()
167
+ })
168
+
169
+ // Graph-wide observability middleware (registered in bunderstack({ middleware: [instrumentation] }))
170
+ export const instrumentation = o.middleware(async ({ context, next, path }) => {
171
+ const startedAt = performance.now()
172
+ try {
173
+ const result = await next()
174
+ return result
175
+ } finally {
176
+ const duration = Math.round(performance.now() - startedAt)
177
+ // context.peekSession() reads resolved session without triggering forced auth on public/webhooks
178
+ const userId = context.peekSession()?.user?.id
179
+ console.log(`[oRPC] ${path.join('.')} - ${duration}ms - User: ${userId ?? 'anon'}`)
180
+ }
181
+ })
182
+ ```
183
+
184
+ ### Rule: Circular Boot-Time Import Prevention
185
+
186
+ `src/bunderstack/auth.ts` and `api/base.ts` must **NEVER** import `app` or `src/bunderstack/index.ts` at module top-level.
187
+ - In `auth.ts`: Read `process.env` directly for secret keys. If an async hook (like email sending) needs the initialized app, use dynamic `import('./index')` inside the callback.
188
+ - In `api/*.ts`: Consume `context.db`, `context.env`, `context.jobs`, `context.storage`, `context.auth` from handler parameters.
189
+
190
+ ---
191
+
192
+ ## 3. Subsystem Architecture
193
+
194
+ ### 3.1 Declarative Access Control & Generated CRUD
195
+
196
+ `defineAccess` automatically exposes REST & RPC CRUD procedures for tables (`list`, `get`, `create`, `update`, `delete`):
197
+
198
+ ```ts
199
+ // src/bunderstack/access.ts
200
+ import { defineAccess } from 'bunderstack'
201
+ import { schema } from './schema'
202
+
203
+ export const access = defineAccess(schema, {
204
+ posts: {
205
+ list: 'public',
206
+ get: 'public',
207
+ create: 'authenticated',
208
+ update: 'owner',
209
+ delete: 'owner',
210
+ ownerColumn: 'authorId', // defaults to userId if present
211
+ filterableColumns: ['authorId', 'category', 'isPublished'],
212
+ sortableColumns: ['createdAt', 'title'],
213
+ defaultSort: { column: 'createdAt', order: 'desc' },
214
+ scope: {
215
+ read: (ctx) => ({ isPublished: true }), // applied to public queries
216
+ },
217
+ },
218
+ // Hide internal or sensitive tables from public CRUD endpoints
219
+ auditLogs: { crud: false },
220
+ systemSettings: { crud: false },
221
+ })
222
+ ```
223
+
224
+ - **Querying CRUD over RPC**:
225
+ ```ts
226
+ await client.posts.list.call({
227
+ filters: { authorId: 'user_123' },
228
+ sort: 'createdAt',
229
+ order: 'desc',
230
+ limit: 20,
231
+ })
232
+ ```
233
+ - **CRUD Updates**: Accept `{ id, ...changes }` directly:
234
+ ```ts
235
+ await client.posts.update.call({ id: 'post_1', title: 'Updated Title' })
236
+ ```
237
+ - **Custom List Procedures**: Use `listSpec` to give custom endpoints the same pagination and filtering behavior:
238
+ ```ts
239
+ import { listSpec } from 'bunderstack'
240
+
241
+ const logSpec = listSpec(schema.auditLogs, {
242
+ filterable: ['level', 'userId'],
243
+ sortable: ['createdAt'],
244
+ defaultSort: { column: 'createdAt', order: 'desc' },
245
+ })
246
+
247
+ export const logsProcedure = adminProcedure
248
+ .input(logSpec.input)
249
+ .handler(logSpec.handler)
250
+ ```
251
+
252
+ ### 3.2 Authentication (`authConfig`)
253
+
254
+ Export a clean Better Auth config and pass it into `bunderstack({ auth: authConfig })`:
255
+
256
+ ```ts
257
+ // src/bunderstack/auth.ts
258
+ import type { BetterAuthConfig } from 'better-auth'
259
+
260
+ export const authConfig = {
261
+ secret: process.env.AUTH_SECRET ?? 'dev-secret-at-least-32-chars-long',
262
+ emailAndPassword: { enabled: true },
263
+ session: { expiresIn: 60 * 60 * 24 * 7 }, // 7 days
264
+ } satisfies BetterAuthConfig
265
+ ```
266
+
267
+ In `schema/index.ts`, ensure all Better Auth tables (`user`, `session`, `account`, `verification`) are aggregated so Drizzle migrations generate them.
268
+
269
+ ### 3.3 Background Jobs & Scheduled Cron
270
+
271
+ Jobs and cron are declared inside `jobs.define`:
272
+
273
+ ```ts
274
+ // src/bunderstack/jobs/index.ts
275
+ import * as v from 'valibot'
276
+
277
+ export const defineJobs = (jobs) =>
278
+ jobs.define({
279
+ sendWelcomeEmail: jobs.job({
280
+ input: v.object({ userId: v.string(), email: v.pipe(v.string(), v.email()) }),
281
+ concurrency: 5,
282
+ timeout: 30_000,
283
+ retries: 3,
284
+ handler: async ({ userId, email }, ctx) => {
285
+ await ctx.email.send({
286
+ to: email,
287
+ subject: 'Welcome!',
288
+ html: '<h1>Welcome to our service</h1>',
289
+ })
290
+ },
291
+ onFailed: async ({ userId }, error, ctx) => {
292
+ console.error(`Failed to send welcome email to user ${userId}`, error)
293
+ },
294
+ }),
295
+ hourlyCleanup: jobs.cron({
296
+ schedule: '0 * * * *', // Five-field UTC cron
297
+ handler: async (_invocation, ctx) => {
298
+ // ctx.db, ctx.env available
299
+ },
300
+ }),
301
+ })
302
+ ```
303
+
304
+ - Enqueue from any handler or service: `await app.jobs.enqueue('sendWelcomeEmail', { userId: '1', email: 'user@example.com' })`
305
+ - Cron runs are automatically registered in the deployment blueprint.
306
+
307
+ ### 3.4 Storage
308
+
309
+ Declare buckets with access control and file restrictions:
310
+
311
+ ```ts
312
+ storage: {
313
+ local: './uploads',
314
+ defaultBucket: 'files',
315
+ buckets: {
316
+ avatars: {
317
+ visibility: 'public',
318
+ access: { create: 'authenticated', get: 'public', delete: 'owner' },
319
+ upload: { maxSize: '5mb', accept: ['image/png', 'image/jpeg', 'image/webp'] },
320
+ transforms: true, // enables on-the-fly resizing via Bun.Image
321
+ },
322
+ documents: {
323
+ visibility: 'private',
324
+ access: { create: 'authenticated', get: 'owner', delete: 'owner' },
325
+ },
326
+ },
327
+ }
328
+ ```
329
+
330
+ - Uploading on server: `await app.storage.upload(key, fileBuffer, 'image/png', { bucket: 'avatars' })`
331
+ - Generating signed URL: `const url = await app.storage.getUrl(key, { expiresIn: 3600, bucket: 'documents' })`
332
+
333
+ ### 3.5 Realtime Publishing
334
+
335
+ - Generated CRUD publishes updates automatically.
336
+ - For custom database writes, publish the **complete returned row** after the transaction commits:
337
+
338
+ ```ts
339
+ const [row] = await ctx.db
340
+ .update(schema.tasks)
341
+ .set({ status: 'completed' })
342
+ .where(eq(schema.tasks.id, input.taskId))
343
+ .returning()
344
+
345
+ // Publish full row with schema table reference
346
+ await ctx.realtime.publish(schema.tasks, 'update', row)
347
+ ```
348
+
349
+ ### 3.6 Typed Errors
350
+
351
+ Raise typed errors in procedures using `errors`:
352
+
353
+ ```ts
354
+ .handler(async ({ context, input, errors }) => {
355
+ const item = await findItem(context.db, input.id)
356
+ if (!item) throw errors.NOT_FOUND({ message: 'Item not found' })
357
+ if (item.locked) throw errors.CONFLICT({ message: 'Item is currently locked' })
358
+ return item
359
+ })
360
+ ```
361
+
362
+ Outside procedures (e.g. in background jobs or domain services):
363
+ ```ts
364
+ import { BunderstackError } from 'bunderstack'
365
+
366
+ throw new BunderstackError('FORBIDDEN', 'Quota exceeded')
367
+ ```
368
+
369
+ ---
370
+
371
+ ## 4. Database Lifecycle & Strict Migration Rules
372
+
373
+ ### Development vs. Production Lifecycle
374
+
375
+ 1. **Local Development (No Migrations Folder):**
376
+ - In dev, `await provision(app)` automatically pushes the schema to the SQLite/libSQL/Postgres database.
377
+ - Developers can rapidly prototype and iterate on table schemas without generating migrations on every change.
378
+
379
+ 2. **Production & Bunderhost Deployments (MANDATORY Migrations):**
380
+ - **Committed migrations are strictly mandatory for production deployments.**
381
+ - Bunderhost will **NOT** run schema push in production; deployment will fail if committed migrations in `migrations/` are missing or out of date.
382
+
383
+ ### CRITICAL MIGRATION RULES
384
+
385
+ > [!CAUTION]
386
+ > **ALL MIGRATIONS MUST BE GENERATED EXCLUSIVELY VIA DRIZZLE-KIT CLI.**
387
+ > - Always run: `bunx drizzle-kit generate` (or `bun run db:generate`).
388
+ > - **NEVER** hand-edit generated migration SQL files.
389
+ > - **NEVER** let an LLM agent write or modify `.sql` files in `migrations/`.
390
+ > - Always commit both the schema changes in `src/bunderstack/schema/` and the newly generated files in `migrations/` together.
391
+
392
+ ---
393
+
394
+ ## 5. Web Handler & Dedicated Worker Process
395
+
396
+ ### TanStack Start Catch-All (`src/routes/api/$.ts`)
397
+
398
+ Delegate all `/api/*` traffic (Better Auth, oRPC RPC, generated CRUD, Storage, Realtime) to a single catch-all handler:
399
+
400
+ ```ts
401
+ // src/routes/api/$.ts
402
+ import { createFileRoute } from '@tanstack/react-router'
403
+ import { createApiHandlers } from 'bunderstack/start'
404
+ import { app } from '../../bunderstack'
405
+
406
+ export const Route = createFileRoute('/api/$')({
407
+ server: {
408
+ handlers: createApiHandlers(app),
409
+ },
410
+ })
411
+ ```
412
+
413
+ *Note: Remove any separate `/api/auth/$`, `/api/trpc/$`, or `/api/cron/*` route files.*
414
+
415
+ ### Dedicated Production Worker (`src/worker.ts`)
416
+
417
+ In production, run background jobs in a dedicated worker process:
418
+
419
+ ```ts
420
+ // src/worker.ts
421
+ import { backend } from './bunderstack/backend'
422
+
423
+ const app = await backend.start({
424
+ env: { ...process.env, BUNDERSTACK_ROLE: 'worker' },
425
+ })
426
+
427
+ console.log('Bunderstack background worker started.')
428
+ await app.runWorker()
429
+ ```
430
+
431
+ Add worker script in `package.json`:
432
+ ```json
433
+ {
434
+ "scripts": {
435
+ "dev": "bun --bun vite dev",
436
+ "build": "vite build",
437
+ "start": "bun dist/server/server.js",
438
+ "worker": "bun src/worker.ts",
439
+ "db:generate": "drizzle-kit generate",
440
+ "blueprint": "bunderstack blueprint",
441
+ "blueprint:check": "bunderstack blueprint --check"
442
+ }
443
+ }
444
+ ```
445
+
446
+ ---
447
+
448
+ ## 6. Testing with Lexical Fixtures
449
+
450
+ Use `backend.test()` to create isolated, disposable test fixtures with in-memory DB, mocked auth, and deterministic job queues:
451
+
452
+ ```ts
453
+ // src/bunderstack/api/posts.test.ts
454
+ import { test, expect } from 'bun:test'
455
+ import { backend } from '../backend'
456
+
457
+ test('creates and retrieves a post', async () => {
458
+ // Fixture is automatically disposed at the end of scope
459
+ await using t = await backend.test({
460
+ database: { schema: 'push' },
461
+ })
462
+
463
+ // Create mock authenticated session
464
+ const identity = t.auth.mockSession({
465
+ id: 'user_1',
466
+ email: 'author@example.com',
467
+ name: 'Author Name',
468
+ })
469
+
470
+ // Typed in-process oRPC client
471
+ const client = t.client(identity)
472
+
473
+ const created = await client.posts.create({ title: 'New Post', content: 'Hello' })
474
+ expect(created.title).toBe('New Post')
475
+
476
+ // Run all queued background jobs deterministically
477
+ await t.jobs.runUntilIdle()
478
+
479
+ // Inspect sent emails
480
+ expect(t.email.sent).toHaveLength(0)
481
+ })
482
+ ```
483
+
484
+ ---
485
+
486
+ ## 7. Bunderhost Deployment Contract
487
+
488
+ ### The Blueprint (`bunderstack.blueprint.yaml`)
489
+
490
+ Bunderhost reads `bunderstack.blueprint.yaml` at the root of the repository to provision infrastructure (databases, buckets, background workers, cron schedules, environment variables).
491
+
492
+ 1. Declare the Bunderstack entry point in `package.json`:
493
+ ```json
494
+ {
495
+ "bunderstack": {
496
+ "entry": "src/bunderstack/backend.ts"
497
+ }
498
+ }
499
+ ```
500
+ 2. Generate and verify the blueprint:
501
+ ```bash
502
+ bunx bunderstack blueprint
503
+ bunx bunderstack blueprint --check
504
+ ```
505
+ 3. Commit `bunderstack.blueprint.yaml` to git.
506
+
507
+ ### Readiness Endpoint (`/api/readiness`)
508
+
509
+ Bunderhost monitors application deployment status via `GET /api/readiness`, which checks database connectivity, applied migrations, and queue backlog.
510
+
511
+ ---
512
+
513
+ ## 8. LLM Documentation & Bunderhost MCP Integration
514
+
515
+ ### Official Bunderstack Documentation for LLMs
516
+
517
+ When working on Bunderstack projects, consult the dedicated LLM references:
518
+ - **Web Documentation**: [https://bunderstack.kcrz.dev/docs](https://bunderstack.kcrz.dev/docs)
519
+ - **Compact LLM Context (`llms.txt`)**: [https://bunderstack.kcrz.dev/docs/llms.txt](https://bunderstack.kcrz.dev/docs/llms.txt) (or local `node_modules/bunderstack/llms.txt`)
520
+ - **Complete LLM Knowledge Base (`llms-full.txt`)**: [https://bunderstack.kcrz.dev/docs/llms-full.txt](https://bunderstack.kcrz.dev/docs/llms-full.txt)
521
+
522
+ ### Bunderhost MCP Server Integration
523
+
524
+ Bunderhost provides a Model Context Protocol (MCP) server that allows coding agents to inspect, manage, and deploy projects.
525
+
526
+ #### Connecting to Bunderhost MCP:
527
+ 1. Generate an Agent Access Token in Bunderhost: **Organization → Agent Access → Issue Token**.
528
+ 2. Connect your MCP client to `https://<bunderhost-host>/mcp` using the token as a `Bearer` credential.
529
+
530
+ #### Key MCP Tools:
531
+ - `list_projects`: List all projects in the organization.
532
+ - `get_project`: Retrieve project configuration, active deployments, and blueprint status.
533
+ - `get_project_readiness`: Check database reachability, migration state, and queue backlog.
534
+ - `create_setup_session`: Open a secure setup session for the user to configure sensitive environment variables in the dashboard.
535
+ - `deploy_project` / `deploy_revision`: Trigger a deployment (requires user confirmation).
536
+ - `get_deployment_logs`: Fetch build and deploy logs.
537
+ - `get_runtime_logs`: Stream runtime container logs.
538
+
539
+ #### Agent Safety Rules for Bunderhost:
540
+ 1. **Secrets Are Never Exposed**: Database passwords, encryption keys, and environment values are never returned by MCP tools. When a new secret is needed, the agent must create a setup session (`create_setup_session`), and the user types the secret in the Bunderhost UI.
541
+ 2. **Mutations Require Confirmation**: Creating projects or deploying revisions require explicit user approval in the MCP client before execution.
542
+
543
+ ---
544
+
545
+ ## 9. Quick Reference & Common Mistakes
546
+
547
+ | Anti-Pattern (Don't Do This) | Canonical Pattern (Do This) |
548
+ | --- | --- |
549
+ | Creating separate `/api/auth/$` and `/api/trpc/$` routes | Single catch-all `src/routes/api/$.ts` with `createApiHandlers(app)` |
550
+ | Creating multiple Drizzle instances in `src/lib/db.ts` | Use `app.db` and `context.db`; export types with `BunderstackDb<typeof schema>` |
551
+ | Constructing `ORPCError` manually | Use `errors.CODE({ message })` or `new BunderstackError('CODE', message)` |
552
+ | Calling `getSession()` inside global middleware | Use `context.peekSession()` for non-blocking observability |
553
+ | Editing `.sql` files in `migrations/` by hand | Always generate with `bunx drizzle-kit generate` and commit untouched |
554
+ | Deploying to Bunderhost with schema push only | Generate and commit Drizzle migrations before deploying |
555
+ | Starting workers inside the web server process in prod | Run dedicated `src/worker.ts` with `app.runWorker()` |
556
+ | Hand-written HTTP `/api/cron/*` endpoints | Use `jobs.cron({ schedule, handler })` |
557
+ | Top-level import of `app` inside `auth.ts` or `api/base.ts` | Consume `context` in handlers or use dynamic `import('./index')` in callbacks |
558
+