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.
- package/dist/cli-skills.d.ts +16 -0
- package/dist/cli-skills.d.ts.map +1 -0
- package/dist/cli-skills.js +116 -0
- package/dist/cli-skills.js.map +1 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +36 -4
- package/dist/cli.js.map +1 -1
- package/llms.txt +360 -0
- package/package.json +3 -1
- package/skills/creating-bunderstack-apps/SKILL.md +46 -0
- package/skills/creating-bunderstack-apps/agents/openai.yaml +4 -0
- package/skills/creating-bunderstack-apps/references/application-structure.md +197 -0
- package/skills/creating-bunderstack-apps/references/runtime-integrations.md +68 -0
- package/skills/creating-bunderstack-apps/references/verification.md +25 -0
- package/skills/migrating-to-bunderstack/SKILL.md +84 -0
- package/skills/migrating-to-bunderstack/agents/openai.yaml +4 -0
- package/skills/migrating-to-bunderstack/references/audit-checklist.md +57 -0
- package/skills/migrating-to-bunderstack/references/runtime-replacements.md +286 -0
|
@@ -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
|
+
```
|