bunderstack 0.17.0-beta.7 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,197 @@
1
+ # Application structure
2
+
3
+ ## Keep the entry declarative
4
+
5
+ The Bunderstack entry constructs and exports `app` (and usually `type App =
6
+ typeof app`), configures the declared capabilities, and calls `provision(app)`
7
+ when the application owns provisioning. Keep unrelated external side effects
8
+ out of its import graph: the blueprint command imports this entry with
9
+ `BUNDERSTACK_INTROSPECT=1`.
10
+
11
+ Start a small API in `src/bunderstack.ts`. Split a meaningful configuration
12
+ into `src/bunderstack/` with `index.ts`, `schema/`, `access.ts`, `auth.ts`,
13
+ `env.ts`, `jobs/`, and `api/` as needed. The entry remains the one place that
14
+ assembles those modules into `createBunderstack()`; do not create parallel app,
15
+ database, or auth instances.
16
+
17
+ ## Aggregate the schema
18
+
19
+ Export every domain, Better Auth, plugin, and Bunderstack internal table from
20
+ the schema object passed to `createBunderstack()`. Include
21
+ `export * from 'bunderstack/schema'` so migrations include the internal tables.
22
+ Define Better Auth tables required by the selected auth flows and plugins; do
23
+ not assume a minimal auth configuration needs every optional provider table.
24
+
25
+ ## Define authorization on the server
26
+
27
+ Use `defineAccess(schema, rules)` for generated CRUD. Give each exposed table
28
+ an explicit operation policy and set `ownerColumn` for owner rules. Keep auth,
29
+ internal, and administrative tables out of generated CRUD unless deliberately
30
+ exposing the supported `user` table.
31
+
32
+ Use `scope.read` and `scope.write` to enforce tenant columns on generated
33
+ lists, reads, and writes. For example, an organization-owned table can derive
34
+ `{ organizationId: ctx.session?.activeOrganizationId ?? '__none__' }`, keeping
35
+ users without an active organization outside tenant rows. Use protected oRPC
36
+ procedures built from `o.protected` when authorization depends on related rows
37
+ or roles; hiding a UI route is not authorization.
38
+
39
+ ## Extend the one API graph
40
+
41
+ Declare the builder once at module scope with `defineApi({ schema, env })`. It
42
+ infers the schema and env types from the values, so no application writes
43
+ `BunderstackApiBuilder<...>` by hand, and it reads nothing at runtime.
44
+
45
+ ```ts
46
+ // src/bunderstack/api/base.ts
47
+ export const o = defineApi({ schema, env: envSchema })
48
+ export const publicProcedure = o.public
49
+ export const protectedProcedure = o.protected
50
+ ```
51
+
52
+ Router modules are plain objects that import the base they need, and the entry
53
+ passes the finished router as `api`:
54
+
55
+ ```ts
56
+ // src/bunderstack/api/projects.ts
57
+ export const projectsRouter = {
58
+ stats: protectedProcedure.input(...).handler(...),
59
+ }
60
+
61
+ // src/bunderstack/api/index.ts
62
+ export const api = { projects: projectsRouter }
63
+
64
+ // entry
65
+ createBunderstack({ schema, database, api })
66
+ ```
67
+
68
+ Do not write a router factory that receives a bag of procedures. That shape
69
+ only existed because `api` used to be a callback. The callback form,
70
+ `api: (o) => ({ ... })`, still works for a router that must be built from the
71
+ framework builder at configuration time.
72
+
73
+ Use `o.public`, `o.protected`, or `o.webhook`; add `.route(...)` only when a
74
+ stable HTTP projection is useful. Generated CRUD, custom procedures, files,
75
+ health, and `realtime.changes` remain in the same typed oRPC graph and client.
76
+
77
+ ## Give a group of procedures its own base
78
+
79
+ A base is an oRPC builder, so `.use()` produces another one. Declare a rule
80
+ once instead of repeating it in every handler that depends on it:
81
+
82
+ ```ts
83
+ export const adminProcedure = o.protected.use(async ({ context, next, errors }) => {
84
+ if (context.user.role !== 'admin') {
85
+ throw errors.FORBIDDEN({ message: 'Admin access required' })
86
+ }
87
+ return next()
88
+ })
89
+ ```
90
+
91
+ `next({ context })` merges into the context and types it for everything
92
+ downstream, which is how an organization scope or a resolved tenant reaches
93
+ handlers without an argument.
94
+
95
+ ## Instrument the whole graph, not one base
96
+
97
+ Bunderstack builds the CRUD, storage, and realtime procedures itself, so they
98
+ never pass through a base the application declares. A middleware attached to
99
+ `o.protected` therefore measures the application's own procedures and leaves
100
+ the generated CRUD — usually the larger share of traffic — unmeasured.
101
+
102
+ Register cross-cutting middleware in the configuration instead:
103
+
104
+ ```ts
105
+ const instrumentation = o.middleware(async ({ context, next, path }) => {
106
+ if (path[0] === 'realtime') return next()
107
+ const startedAt = performance.now()
108
+ try {
109
+ return await next()
110
+ } finally {
111
+ record(path.join('.'), performance.now() - startedAt, context.peekSession()?.user?.id)
112
+ }
113
+ })
114
+
115
+ createBunderstack({ schema, database, middleware: [instrumentation], api })
116
+ ```
117
+
118
+ Three rules apply to a graph-wide middleware. It runs before authentication, so
119
+ `context.user` does not exist there. Read the caller with
120
+ `context.peekSession()` after `await next()`, never `getSession()`: forcing the
121
+ session makes every request pay for authentication, including signed webhooks
122
+ that never needed it, and `peekSession()` is for observability only, never for
123
+ authorization. A realtime subscription is one long-lived call, so code after
124
+ `await next()` runs when the stream closes, not when it starts.
125
+
126
+ ## Raise declared errors
127
+
128
+ Every procedure carries one error map: `BAD_REQUEST`, `UNAUTHORIZED`,
129
+ `FORBIDDEN`, `NOT_FOUND`, `CONFLICT`, `PAYLOAD_TOO_LARGE`,
130
+ `TOO_MANY_REQUESTS`. Inside a handler or middleware, raise from the `errors`
131
+ argument, with extra context in `data.details`:
132
+
133
+ ```ts
134
+ throw errors.NOT_FOUND({ message: 'Project not found' })
135
+ ```
136
+
137
+ Code outside a handler — a service function or a job — has no `errors`
138
+ argument. Throw `BunderstackError`, which the framework maps to the same typed
139
+ error. Do not construct `ORPCError` by hand anywhere.
140
+
141
+ ## Give list endpoints the shared contract
142
+
143
+ `listSpec(table, options)` gives a procedure you write the same filter, sort,
144
+ cursor, and count contract the generated CRUD list uses. Apply both parts to
145
+ your own base, which is what preserves the row type to the client:
146
+
147
+ ```ts
148
+ const logsList = listSpec(appLogs, { filterable: ['level'], sortable: ['createdAt'] })
149
+ logs: adminProcedure.input(logsList.input).handler(logsList.handler)
150
+ ```
151
+
152
+ It reads no `access` configuration; the base procedure carries the policy.
153
+
154
+ ## Type helpers that take the database
155
+
156
+ A service module cannot reach `typeof app.db` without an import cycle back to
157
+ the entry. Use `BunderstackDb<typeof schema>` and `BunderstackTx<typeof schema>`
158
+ instead of `any`.
159
+
160
+ ## Keep the entry out of its own import graph
161
+
162
+ The api router and everything it imports are evaluated when the entry is
163
+ imported. A module in that graph that imports the app back — a bot client
164
+ reading `db`, a logger reading `app.env` — closes a cycle and breaks module
165
+ initialization. Load the app lazily in such a module.
166
+
167
+ ## Keep credentials environment-owned
168
+
169
+ Declare validated server and browser-safe client variables in `env`. Server
170
+ variables must not use the `PUBLIC_` prefix; client variables must use it.
171
+ Use `app.env` (and `ctx.env`) rather than duplicating unchecked configuration.
172
+ Commit `.env.example` with names and safe placeholders only. Keep production
173
+ secrets, database URLs, storage credentials, and auth secrets in the runtime
174
+ environment.
175
+
176
+ Use local libSQL storage and console email for development when appropriate;
177
+ declare production adapters and credentials through configuration and runtime
178
+ environment rather than hard-coding them.
179
+
180
+ ## Publish direct writes
181
+
182
+ Generated CRUD publishes realtime changes automatically. For a write through
183
+ `app.db` or `ctx.db`, return the complete changed row, wait for its transaction
184
+ to commit, then publish it with the table object:
185
+
186
+ ```ts
187
+ const [task] = await ctx.db
188
+ .update(schema.tasks)
189
+ .set({ status: 'done' })
190
+ .where(eq(schema.tasks.id, taskId))
191
+ .returning()
192
+
193
+ await ctx.realtime.publish(schema.tasks, 'update', task)
194
+ ```
195
+
196
+ Do not publish from inside an enclosing transaction. The complete row lets the
197
+ existing access filter evaluate owner and read-scope columns.
@@ -0,0 +1,68 @@
1
+ # Runtime integrations
2
+
3
+ `app.handler` is the single Web Standard `Request -> Response` integration
4
+ point. Mount it once; do not recreate routing, auth, or database layers in a
5
+ framework adapter.
6
+
7
+ ## TanStack Start
8
+
9
+ TanStack Start owns the web process. Use `bunderstackStart<App>()` for the
10
+ client and mount the catch-all route with `createApiHandlers(app)`:
11
+
12
+ ```ts
13
+ export const Route = createFileRoute('/api/$')({
14
+ server: { handlers: createApiHandlers(app) },
15
+ })
16
+ ```
17
+
18
+ Keep the client setup in `src/api.ts`, not `src/client.ts`, which is a reserved
19
+ Start entry point. Import `App` as a type so the browser does not load server
20
+ runtime code.
21
+
22
+ ## Standalone Bun and other runtimes
23
+
24
+ For a standalone server, pass the handler directly:
25
+
26
+ ```ts
27
+ Bun.serve({ fetch: app.handler })
28
+ ```
29
+
30
+ Other server frameworks must adapt their request and response objects to the
31
+ Web Standard pair, then delegate to `app.handler`. Astro adapters therefore
32
+ convert to and from Web Standard requests and responses. A browser-only React
33
+ SPA has no server request handler: run a separate Bun API process and point the
34
+ frontend's API base URL at that process.
35
+
36
+ ## Background runtime
37
+
38
+ Declare queue jobs with `jobs: (j) => j.define(...)`, then run them in a
39
+ separate production process:
40
+
41
+ ```ts
42
+ import { app } from './bunderstack'
43
+
44
+ await app.runWorker()
45
+ ```
46
+
47
+ Do not start a production worker or cron scheduler from the web entry.
48
+ `j.cron()` is delivered by the platform over authenticated HTTP. Queue handlers
49
+ are at-least-once, so make them idempotent and declare input validation and
50
+ retries.
51
+
52
+ If workers publish realtime events, configure the same shared Redis transport
53
+ for web and worker processes. `realtime: true` alone is process-local and is
54
+ only safe when the worker is embedded with `app.startWorker()` for local work.
55
+
56
+ ## Realtime and synced collections
57
+
58
+ Clients consume the typed `realtime.changes` async iterator. Idle HTTP streams
59
+ receive a transport-only `heartbeat` every five seconds; the Bunderstack query
60
+ client filters it before cache callbacks and does not advance the Publisher
61
+ resume ID. Do not add an application polling loop or publish heartbeat events
62
+ through oRPC Publisher.
63
+
64
+ `bunderstack-sync` reconciles successful mutations from the canonical row
65
+ returned by generated CRUD, without a follow-up list refetch. Realtime echoes
66
+ are idempotent, and reconnect performs the full refetch used to repair drift.
67
+ Keep custom replacement procedures compatible by returning the complete row,
68
+ including `id`.
@@ -0,0 +1,25 @@
1
+ # Verification contract
2
+
3
+ Run these gates from the application root after changing dependencies,
4
+ configuration, or application code:
5
+
6
+ ```sh
7
+ bun install
8
+ bun test
9
+ bun run typecheck
10
+ bun run build
11
+ bun run blueprint
12
+ bun run blueprint:check
13
+ ```
14
+
15
+ `bun run blueprint` generates the committed `bunderstack.blueprint.yaml` from
16
+ the configured Bunderstack entry. Set `package.json#bunderstack.entry` when the
17
+ entry is not `src/bunderstack.ts`. `bun run blueprint:check` must pass in CI so
18
+ the committed declaration matches the application.
19
+
20
+ Before production, generate and commit the Drizzle `migrations/` folder. With
21
+ no migrations folder, `provision(app)` uses the development schema-push loop
22
+ (and needs drizzle-kit). Once migrations are committed, it applies pending
23
+ migrations without importing drizzle-kit. Keep the generated migrations,
24
+ blueprint, tests, worker entry, API mount, and deployment scripts under version
25
+ control; never commit secrets, databases, uploads, or build output.
@@ -0,0 +1,84 @@
1
+ ---
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.
4
+ ---
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 app 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 `createBunderstack()`, re-export `app.auth` and `app.db`
35
+ from the entry, and let the `env` passed to `createBunderstack()` be the only
36
+ validated source. A more specific file route also shadows the catch-all, so a
37
+ surviving `/api/auth/$` silently keeps serving the instance you meant to delete.
38
+
39
+ ## Replacements
40
+
41
+ | Legacy shape | Current contract |
42
+ | ------------------------------------------------------------- | --------------------------------------------------------------------------------- |
43
+ | Hand-written `ALL: ({ request }) => app.handler(request)` map | `createApiHandlers(app)` on one `/api/$` route |
44
+ | Separate `/api/auth/$` or `/api/trpc/$` mounts | Deleted; the catch-all serves Better Auth and the unified oRPC `/api/rpc/*` graph |
45
+ | tRPC router and procedure clients | Router modules over `defineApi` bases, passed as `api`, with one inferred client |
46
+ | Per-file router factories taking a bag of procedures | Bases exported from one module, imported by plain router objects |
47
+ | Hand-built `ORPCError` or a second error model | `errors.CODE({ message })`, or `BunderstackError` outside a handler |
48
+ | Tracing attached to an application base | `middleware: [...]`, which also covers the generated CRUD |
49
+ | Hand-rolled limit/offset/count blocks | `listSpec(table, options)` applied to your own base |
50
+ | `any`-typed db parameters in helpers | `BunderstackDb<typeof schema>` and `BunderstackTx<typeof schema>` |
51
+ | Worker started from the web entry | `src/worker.ts` owning `app.runWorker()` |
52
+ | `/api/cron/*` guarded by a shared secret | `jobs.cron()` with platform delivery |
53
+ | Channel-and-payload realtime publishing | `ctx.realtime.publish(schema.tasks, 'update', row)` after the write commits |
54
+ | AWS or Tigris SDK wrapper | `app.storage` buckets |
55
+ | Resend SDK wrapper | `app.email.send(...)` |
56
+ | `createEnv()` beside the app | `env` passed to `createBunderstack()` |
57
+ | Implicit database driver | Explicit adapter, `database: { adapter: libsql(), url }` |
58
+ | Schema push in production | Committed Drizzle `migrations/`, applied by `provision(app)` |
59
+ | Undeclared deployment | `package.json#bunderstack.entry` and a checked blueprint |
60
+
61
+ Read [runtime replacements](references/runtime-replacements.md) before writing
62
+ any replacement above; it holds the current snippets and the realtime transport
63
+ rule for multi-process deployments. Read the
64
+ [audit checklist](references/audit-checklist.md) during phase 1 to inventory
65
+ ownership, and again at phase 7 before each deletion.
66
+
67
+ ## Deletion gate
68
+
69
+ Do not delete a legacy module until its replacement is mounted, every call site
70
+ imports the replacement, a migration contract test covers the behaviour, and
71
+ `bun run typecheck` reports no remaining importers. A one-release re-export
72
+ shim is acceptable when call sites are numerous; the shim is deleted under this
73
+ same gate. Uninstall the replaced SDK package in the commit that removes its
74
+ last importer, so a stale wrapper cannot be reintroduced silently.
75
+
76
+ Tests and scripts that construct their own app instance must call
77
+ `app.close()`.
78
+
79
+ ## Production gate
80
+
81
+ Before the cutover deploy: committed migrations exist, the worker runs as its
82
+ own process, web and worker share a realtime transport if jobs publish events,
83
+ `package.json#bunderstack.entry` points at the entry, and
84
+ `bun run blueprint:check` passes in CI.
@@ -0,0 +1,4 @@
1
+ interface:
2
+ display_name: "Migrating to Bunderstack"
3
+ short_description: "Move existing applications onto Bunderstack"
4
+ default_prompt: "Use $migrating-to-bunderstack to migrate this application to Bunderstack."
@@ -0,0 +1,57 @@
1
+ # Audit checklist
2
+
3
+ Use this table at phase 1 to record who owns each capability today, and again
4
+ at phase 7 before deleting anything. Fill the evidence column with a command
5
+ output or a file reference, not an assertion.
6
+
7
+ | Capability | Legacy shape to find | Authoritative replacement | Evidence that the move is done | Deletion gate |
8
+ | ---------------------- | ------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- |
9
+ | Auth instance | A second `betterAuth({...})` call, a custom session resolver, a patched `getSession` | `authConfig` passed to `createBunderstack()`; consumers import `app.auth` | One `betterAuth` construction in the repository; a protected route rejects an unauthenticated request | No importer of the legacy auth module remains |
10
+ | Auth schema | Auth tables generated into a legacy schema directory | Auth tables in the one schema aggregate | Generated migration includes `user`, `session`, `account`, `verification` | Legacy schema directory has no importers |
11
+ | Database client | A module constructing its own libSQL/Postgres client | `app.db`, re-exported from the entry | The entry is the only place calling the adapter factory | Legacy `db` module deleted or reduced to a re-export, then deleted |
12
+ | API mounting | Hand-written handler maps; separate `/api/auth/$`, `/api/trpc/$` | `createApiHandlers(app)` on one `/api/$` | Auth and oRPC requests succeed with only the catch-all present | Shadowing route files deleted |
13
+ | Custom API routes | Route files doing CRUD the framework can generate | Generated CRUD plus `defineAccess`, or an `o.protected` procedure | Access rules cover each exposed table; a cross-owner request is denied | Route file has no client callers |
14
+ | Access control | Per-endpoint session checks and hand-written SQL filters | `defineAccess(schema, rules)` with `scope.read` / `scope.write` | A test asserts a second user cannot read or write the first user's rows | Manual filter helpers unused |
15
+ | Jobs | BullMQ or a bespoke queue module | `jobs.define({ ... })` and `app.jobs.enqueue(...)` | Job appears in `app.manifest.background.jobs` | No queue library importer; package uninstalled |
16
+ | Cron | `/api/cron/*` guarded by a shared secret | `jobs.cron({ schedule, handler })` | Cron task appears in the blueprint | Cron route file and its secret removed from env |
17
+ | Worker topology | `startWorker()` or a queue bootstrap in the web entry | `src/worker.ts` calling `app.runWorker()`, run as its own process | Web entry starts no worker; the worker command exists in deployment config | Worker process is deployed before the embedded call is removed |
18
+ | Realtime | Custom WebSocket server, manual pub/sub, channel-and-payload publishing | `realtime` config plus `ctx.realtime.publish(table, event, row)` after commit | A direct write reaches a subscriber with the complete row | Custom transport deleted; shared Redis configured for multi-process |
19
+ | Storage | AWS or Tigris SDK wrapper, custom multipart upload route | Declared buckets and `app.storage` | Upload, signed URL, and delete work through the facade | Wrapper deleted and SDK uninstalled |
20
+ | Email | Resend or SMTP SDK wrapper | `email` config and `app.email.send(...)` | A send succeeds through the configured provider | Wrapper deleted and SDK uninstalled |
21
+ | Env | `createEnv()` beside the app, `dotenv`, unchecked `process.env` reads | `env` passed to `createBunderstack()`; `app.env` / `ctx.env` | Boot fails with a clear message when a required variable is missing | Legacy env module unused; `.env.example` lists names only |
22
+ | API declaration | Router factories taking a bag of procedures; hand-written builder generics | `defineApi({ schema, env })` bases in one module, plain router objects, `api` object | A router module imports its base and exports an object; no factory remains | `BunderstackApiBuilder<...>` and `os.$context<...>()` deleted |
23
+ | Observability | Tracing or logging attached to an application procedure base | `middleware: [...]` in the config, which also reaches the generated CRUD | A generated CRUD request produces a span or log line | Per-base instrumentation removed |
24
+ | Errors | `new ORPCError(...)` at call sites, or a second error model | `errors.CODE({ message })`; `BunderstackError` outside a handler | A failing request answers the declared status, not 500 | `ORPCError` import gone from the api layer |
25
+ | List endpoints | Hand-rolled limit/offset/filter/count blocks repeated per table | `listSpec(table, options)` applied to your own base | The endpoint accepts cursor and `count: true` and answers `ListResult` | Duplicated paging helpers deleted |
26
+ | Migrations | Schema push against production | Committed Drizzle `migrations/`, applied by `provision(app)` | `migrations/` is under version control and applies cleanly to an empty database | Push command removed from deployment |
27
+ | Deployment declaration | No `bunderstack.entry`, no blueprint | `package.json#bunderstack.entry` and a committed blueprint | `bun run blueprint:check` passes in CI | Deployment reads the blueprint rather than ad-hoc process config |
28
+ | App lifetime | Tests and scripts leaking app instances | `app.close()` in a `finally` or `afterEach` | The test run exits without hanging | — |
29
+
30
+ ## Reading a partially migrated application
31
+
32
+ Some applications are already on Bunderstack for part of their surface. The
33
+ audit is the same, but the answer to "who owns this capability" is often _both_,
34
+ which is the state that causes production incidents.
35
+
36
+ A real example: an application whose Bunderstack modules live in
37
+ `src/bunderstack/` while `src/lib/` still holds an auth config, a schema
38
+ directory, and SDK wrappers, whose `package.json` has no `bunderstack.entry`,
39
+ no worker command, and no blueprint scripts, and whose deployment runs a single
40
+ web command. Nothing there is broken in development. In production it means the
41
+ declared entry is unknown to the host, queue jobs run inside web replicas or
42
+ not at all, and two auth configurations disagree about sessions.
43
+
44
+ Treat that shape as a sequence of the gates above, not as a file list to copy.
45
+ The directory names in any one application are incidental; the ownership
46
+ question is not.
47
+
48
+ ## Order that keeps the application working
49
+
50
+ Auth and database first, because everything else reads them. Then access and
51
+ API mounting, so authorization is enforced in one place before routes move.
52
+ Then storage, email, jobs, and cron, which are independent of each other. Then
53
+ realtime, which depends on the write paths already being correct. Migrations
54
+ and the blueprint last, because they declare the finished shape.
55
+
56
+ Deleting in the reverse order of adoption keeps the system reversible: the
57
+ replacement is live and tested before its predecessor stops existing.