bunderstack 0.17.0-beta.8 → 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,286 @@
1
+ # Runtime replacements
2
+
3
+ Current snippets for each capability a migration moves onto Bunderstack. Adapt
4
+ names; do not change the contracts.
5
+
6
+ ## Modular entry
7
+
8
+ A migrated application has enough configuration to justify `src/bunderstack/`
9
+ with `index.ts`, `schema/`, `access.ts`, `auth.ts`, `env.ts`, `jobs/`, and
10
+ `api/`. The entry is the only place that assembles them:
11
+
12
+ ```ts
13
+ import { createBunderstack } from 'bunderstack'
14
+ import { libsql } from 'bunderstack/database/libsql'
15
+ import { provision } from 'bunderstack/provision'
16
+ import { access } from './access'
17
+ import { authConfig } from './auth'
18
+ import { envSchema } from './env'
19
+ import { defineJobs } from './jobs'
20
+ import { schema } from './schema'
21
+ import * as v from 'valibot'
22
+
23
+ export async function createApp(options: { databaseUrl?: string } = {}) {
24
+ return createBunderstack({
25
+ schema,
26
+ access,
27
+ env: envSchema,
28
+ database: {
29
+ adapter: libsql(),
30
+ url: options.databaseUrl ?? process.env.DATABASE_URL ?? 'file:./data.db',
31
+ },
32
+ auth: authConfig,
33
+ email: { from: process.env.EMAIL_FROM ?? 'App <no-reply@example.com>' },
34
+ storage: {
35
+ local: './uploads',
36
+ defaultBucket: 'files',
37
+ buckets: {
38
+ files: {
39
+ visibility: 'private',
40
+ access: { create: 'authenticated', get: 'owner', delete: 'owner' },
41
+ },
42
+ },
43
+ },
44
+ realtime: process.env.REDIS_URL ? { redis: process.env.REDIS_URL } : true,
45
+ jobs: defineJobs,
46
+ middleware: [instrumentation],
47
+ api,
48
+ })
49
+ }
50
+
51
+ // api/base.ts — the builder is a module value, so router modules import the
52
+ // bases they need instead of receiving them through the config callback.
53
+ //
54
+ // export const o = defineApi({ schema, env: envSchema })
55
+ // export const protectedProcedure = o.protected
56
+ // export const instrumentation = o.middleware(async ({ next }) => next())
57
+ //
58
+ // api/index.ts — plain objects, no factories.
59
+ //
60
+ // export const api = { projects: projectsRouter }
61
+
62
+ export const app = await createApp()
63
+ export const { db, auth, env } = app
64
+ export type App = typeof app
65
+
66
+ await provision(app)
67
+ ```
68
+
69
+ The database adapter is imported explicitly; there is no implicit driver. Keep
70
+ the factory for tests that need an isolated `file::memory:` database.
71
+
72
+ Keep unrelated external side effects out of this import graph. The blueprint
73
+ command imports the entry with `BUNDERSTACK_INTROSPECT=1`, so a module that
74
+ connects to a queue or calls a third-party API at import time breaks
75
+ introspection.
76
+
77
+ Aggregate every domain, Better Auth, plugin, and internal table in the schema
78
+ object, including `export * from 'bunderstack/schema'`, so migrations cover the
79
+ internal tables.
80
+
81
+ ## Auth
82
+
83
+ Export a plain `authConfig` and pass it in. Never construct a second Better
84
+ Auth instance, a custom session resolver, or a monkey-patched `getSession`;
85
+ consumers import `app.auth`.
86
+
87
+ `src/bunderstack/auth.ts` reads `process.env` directly at module scope rather
88
+ than importing the entry, and uses a dynamic `import('./index')` inside async
89
+ callbacks such as email rendering. Importing the entry from `auth.ts` creates a
90
+ circular evaluation loop at boot.
91
+
92
+ ## TanStack Start route
93
+
94
+ One catch-all route, built by the adapter rather than by hand:
95
+
96
+ ```ts
97
+ import { createApiHandlers } from 'bunderstack-start'
98
+ import { createFileRoute } from '@tanstack/react-router'
99
+ import { app } from '../../bunderstack'
100
+
101
+ export const Route = createFileRoute('/api/$')({
102
+ server: { handlers: createApiHandlers(app) },
103
+ })
104
+ ```
105
+
106
+ Delete `/api/auth/$`, `/api/trpc/$`, and `/api/cron/*`. The catch-all serves
107
+ Better Auth plus the unified oRPC graph at `/api/rpc/*`. A more specific file
108
+ route wins over the catch-all, so any survivor keeps serving the legacy path.
109
+ Other runtimes adapt their request and response objects to the Web Standard
110
+ pair and delegate to `app.handler`; a standalone Bun process uses
111
+ `Bun.serve({ fetch: app.handler })`.
112
+
113
+ ## Worker
114
+
115
+ Production queue work is its own process:
116
+
117
+ ```ts
118
+ // src/worker.ts
119
+ import { app } from './bunderstack'
120
+
121
+ await app.runWorker()
122
+ ```
123
+
124
+ Run it with `bun src/worker.ts` as a separate process command. Do not start a
125
+ production worker or cron scheduler from the web entry: every web replica would
126
+ run its own worker and compete for the same jobs.
127
+
128
+ `runWorker()` refuses to start when jobs could publish realtime events over the
129
+ in-memory broker, because a separate process cannot reach web subscribers
130
+ through it. Configure the same `REDIS_URL` (or `realtime.redis`) for web and
131
+ worker. The embedded `app.startWorker()` remains correct for local development
132
+ and single-process deployments that acknowledge process-local realtime; pass
133
+ `allowProcessLocalRealtime: true` only when the worker genuinely never
134
+ publishes.
135
+
136
+ ## Jobs and cron
137
+
138
+ ```ts
139
+ import * as v from 'valibot'
140
+
141
+ export const defineJobs = (jobs) =>
142
+ jobs.define({
143
+ generateReport: jobs.job({
144
+ input: v.object({ reportId: v.pipe(v.string(), v.minLength(1)) }),
145
+ concurrency: 1,
146
+ timeout: 10 * 60_000,
147
+ handler: async ({ reportId }, ctx) => buildReport(reportId, ctx),
148
+ onFailed: async ({ reportId }, error, ctx) =>
149
+ markFailed(reportId, error, ctx),
150
+ }),
151
+ archiveStale: jobs.cron({
152
+ // A cron handler receives the invocation first, then the job context.
153
+ schedule: '0 3 * * *',
154
+ handler: async (_invocation, ctx) => archiveStale(ctx),
155
+ }),
156
+ })
157
+ ```
158
+
159
+ Enqueue with `app.jobs.enqueue('generateReport', { reportId })`. Queue handlers
160
+ are at-least-once, so make them idempotent. `jobs.cron()` is delivered by the
161
+ platform over authenticated HTTP and appears in the blueprint; a hand-rolled
162
+ `/api/cron/*` route with a shared secret is invisible to the host and is
163
+ replaced, not kept alongside.
164
+
165
+ ## Direct realtime writes
166
+
167
+ Generated CRUD publishes automatically. A write through `app.db` or `ctx.db`
168
+ publishes explicitly, with the table object and the complete returned row,
169
+ after the transaction commits:
170
+
171
+ ```ts
172
+ const [task] = await ctx.db
173
+ .update(schema.tasks)
174
+ .set({ status: 'done' })
175
+ .where(eq(schema.tasks.id, taskId))
176
+ .returning()
177
+
178
+ await ctx.realtime.publish(schema.tasks, 'update', task)
179
+ ```
180
+
181
+ The complete row is required so the access filter can evaluate owner and
182
+ read-scope columns. Do not publish from inside an enclosing transaction, and do
183
+ not publish a partial patch.
184
+
185
+ Clients subscribe through the typed `realtime.changes` async iterator. Its
186
+ transport emits an internal `heartbeat` during idle periods; the official
187
+ query client consumes it automatically without updating cache state or the
188
+ Publisher resume ID. Delete custom polling, keepalive, SSE registration, and
189
+ client reconnect loops instead of wrapping them around the oRPC stream.
190
+
191
+ For TanStack DB applications, use `bunderstack-sync`. Successful mutations are
192
+ reconciled from their complete server response without a follow-up `list`
193
+ refetch, and same-row updates are coalesced while a request is in flight.
194
+
195
+ ## Access
196
+
197
+ Replace per-endpoint session checks and hand-written SQL filters with
198
+ `defineAccess(schema, rules)`:
199
+
200
+ ```ts
201
+ export const access = defineAccess(schema, {
202
+ projects: {
203
+ list: 'authenticated',
204
+ get: 'owner',
205
+ create: 'authenticated',
206
+ update: 'owner',
207
+ delete: 'owner',
208
+ ownerColumn: 'ownerId',
209
+ scope: { read: (ctx) => ({ ownerId: ctx.user?.id ?? '__none__' }) },
210
+ },
211
+ appLogs: { crud: false },
212
+ })
213
+ ```
214
+
215
+ Keep auth, internal, and administrative tables out of generated CRUD. Use
216
+ `o.protected` procedures when authorization depends on a related row or a
217
+ role; hiding a UI route is not authorization.
218
+
219
+ ## Storage
220
+
221
+ ```ts
222
+ await app.storage.upload(key, body, contentType, { bucket: 'files' })
223
+ const url = await app.storage.getUrl(key, { expiresIn: 3600 })
224
+ await app.storage.delete(fileId)
225
+ ```
226
+
227
+ Buckets are declared in `createBunderstack()` with their own visibility and
228
+ access rules. Delete the AWS or Tigris wrapper and uninstall the SDK. A custom
229
+ multipart upload route is replaced by the bucket's own upload route unless it
230
+ performs domain work that cannot move into a job.
231
+
232
+ ## Email
233
+
234
+ ```ts
235
+ await app.email.send({ to, subject, html })
236
+ ```
237
+
238
+ Configure `email: { from, provider }`. `provider` defaults to `resend` when
239
+ `RESEND_API_KEY` is set and `console` in development. The facade uses Web
240
+ Standard `fetch`, so the `resend` package is uninstalled.
241
+
242
+ ## Env
243
+
244
+ Pass `envSchema` to `createBunderstack({ env: envSchema })` and read `app.env`
245
+ or `ctx.env`. Remove `@t3-oss/env-core` `createEnv()` calls and `dotenv`; Bun
246
+ loads `.env` itself. Server variables must not use the `PUBLIC_` prefix, and
247
+ browser-safe variables must. Declared env appears in the deployment blueprint,
248
+ which is how the host learns what the application needs. Commit `.env.example`
249
+ with names and safe placeholders only.
250
+
251
+ ## Provisioning, migrations, and blueprint
252
+
253
+ `provision(app)` uses the development schema-push loop while no `migrations/`
254
+ folder exists, and applies committed migrations once one does. Generate and
255
+ commit migrations before production:
256
+
257
+ ```json
258
+ {
259
+ "bunderstack": { "entry": "src/bunderstack/index.ts" },
260
+ "scripts": {
261
+ "worker": "bun src/worker.ts",
262
+ "db:generate": "drizzle-kit generate",
263
+ "blueprint": "bunderstack blueprint",
264
+ "blueprint:check": "bunderstack blueprint --check"
265
+ }
266
+ }
267
+ ```
268
+
269
+ `bun run blueprint` regenerates the committed `bunderstack.blueprint.yaml` from
270
+ the entry; `bun run blueprint:check` must pass in CI so the committed
271
+ declaration matches the application. Never commit secrets, databases, uploads,
272
+ or build output.
273
+
274
+ ## Test and script ownership
275
+
276
+ A test or script that constructs its own app owns its lifetime:
277
+
278
+ ```ts
279
+ const app = await createApp({ databaseUrl: 'file::memory:' })
280
+ try {
281
+ await provision(app, { force: true })
282
+ // ...
283
+ } finally {
284
+ await app.close()
285
+ }
286
+ ```