bunderstack 0.24.0 → 0.24.2
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/CHANGELOG.md +21 -0
- package/README.md +1 -0
- package/dist/auth.d.ts +20 -9
- package/dist/auth.d.ts.map +1 -1
- package/dist/auth.js +3 -8
- package/dist/auth.js.map +1 -1
- package/dist/backend.d.ts +4 -4
- package/dist/backend.d.ts.map +1 -1
- package/dist/backend.js.map +1 -1
- package/dist/config.d.ts +10 -6
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js.map +1 -1
- package/dist/provision-internals.d.ts +1 -1
- package/dist/provision-internals.js +1 -1
- package/dist/provision-internals.js.map +1 -1
- package/dist/provision-runtime.d.ts +7 -0
- package/dist/provision-runtime.d.ts.map +1 -0
- package/dist/provision-runtime.js +50 -0
- package/dist/provision-runtime.js.map +1 -0
- package/dist/provision-schema.d.ts +16 -0
- package/dist/provision-schema.d.ts.map +1 -0
- package/dist/provision-schema.js +63 -0
- package/dist/provision-schema.js.map +1 -0
- package/dist/provision.d.ts +4 -21
- package/dist/provision.d.ts.map +1 -1
- package/dist/provision.js +10 -111
- package/dist/provision.js.map +1 -1
- package/dist/runtime.d.ts +9 -7
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +2 -3
- package/dist/runtime.js.map +1 -1
- package/dist/testing/fixture.d.ts +1 -1
- package/dist/testing/fixture.d.ts.map +1 -1
- package/dist/testing/fixture.js +1 -1
- package/dist/testing/fixture.js.map +1 -1
- package/llms-full.txt +416 -226
- package/llms.txt +4 -3
- package/package.json +5 -1
- package/skills/creating-bunderstack-apps/references/verification.md +7 -6
- package/skills/migrating-to-bunderstack/SKILL.md +22 -20
- package/skills/migrating-to-bunderstack/references/runtime-replacements.md +4 -3
package/llms-full.txt
CHANGED
|
@@ -299,7 +299,8 @@ Major optional groups:
|
|
|
299
299
|
|
|
300
300
|
- `access`: generated CRUD exposure, ownership, filters, sorting, and guards;
|
|
301
301
|
- `auth` / `authResolver`: Better Auth and custom session resolution;
|
|
302
|
-
- `
|
|
302
|
+
- `env`: declared environment, validated before anything reads it;
|
|
303
|
+
- `storage`, `messaging`, and `jobs`: application facilities;
|
|
303
304
|
- `api`: your oRPC router, as an object or as `(o) => router`;
|
|
304
305
|
- `middleware`: oRPC middleware applied to every procedure in the graph;
|
|
305
306
|
- `realtime`: memory or Redis Publisher configuration;
|
|
@@ -307,10 +308,18 @@ Major optional groups:
|
|
|
307
308
|
|
|
308
309
|
See [Configuration](/docs/configuration) for examples and defaults.
|
|
309
310
|
|
|
310
|
-
`bunderstack()` only declares the backend. `
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
311
|
+
`bunderstack(config)` only declares the backend. `database`, `storage`,
|
|
312
|
+
`messaging`, and `realtime` also accept a function of the validated
|
|
313
|
+
environment, and `auth` accepts a builder over `{ db, env }`. Those functions
|
|
314
|
+
run once per operation — never at import time — and must supply values, not
|
|
315
|
+
shapes: blueprint generation rejects a declaration whose shape depends on a
|
|
316
|
+
value.
|
|
317
|
+
|
|
318
|
+
`backend.inspect({ env })` resolves the declaration and returns its manifest
|
|
319
|
+
without opening a database, a bucket, a provider connection, or a worker.
|
|
320
|
+
`await backend.start({ env })` materializes a production runtime, and
|
|
321
|
+
`await backend.test()` creates an isolated test fixture owned by the current
|
|
322
|
+
lexical scope.
|
|
314
323
|
|
|
315
324
|
## `BunderstackApp`
|
|
316
325
|
|
|
@@ -320,7 +329,7 @@ type BunderstackApp = {
|
|
|
320
329
|
db: DbFor<TSchema>
|
|
321
330
|
auth: AuthInstance
|
|
322
331
|
storage: StorageFacade
|
|
323
|
-
|
|
332
|
+
messaging: MessagingFacades<TMessaging>
|
|
324
333
|
env: ValidatedEnv<TEnv>
|
|
325
334
|
jobs: JobsFacade<TJobs>
|
|
326
335
|
realtime: RealtimeFacade<TSchema>
|
|
@@ -340,7 +349,7 @@ type BunderstackApp = {
|
|
|
340
349
|
## API builder
|
|
341
350
|
|
|
342
351
|
```ts
|
|
343
|
-
const o = defineApi({ schema, env: envSchema })
|
|
352
|
+
const o = defineApi({ schema, env: envSchema, messaging })
|
|
344
353
|
|
|
345
354
|
o.public // no session resolution
|
|
346
355
|
o.protected // resolves the session, narrows context.user
|
|
@@ -348,8 +357,9 @@ o.webhook // public, preserves the exact raw body
|
|
|
348
357
|
o.middleware(fn) // a standalone middleware over ApiContext
|
|
349
358
|
```
|
|
350
359
|
|
|
351
|
-
`defineApi` infers `TSchema` and
|
|
352
|
-
application never writes `BunderstackApiBuilder<…>` by hand.
|
|
360
|
+
`defineApi` infers `TSchema`, `TEnv`, and the channel record from the values it
|
|
361
|
+
receives, so an application never writes `BunderstackApiBuilder<…>` by hand.
|
|
362
|
+
`messaging` is optional; without it `context.messaging` stays open. It reads nothing
|
|
353
363
|
at runtime and can be called at module scope. `createApiBuilder<TSchema,
|
|
354
364
|
TEnv>()` remains available when you want to pass the generics explicitly.
|
|
355
365
|
|
|
@@ -361,7 +371,7 @@ type ApiContext = {
|
|
|
361
371
|
db: DbFor<TSchema>
|
|
362
372
|
env: ValidatedEnv<TEnv>
|
|
363
373
|
storage: StorageFacade
|
|
364
|
-
|
|
374
|
+
messaging: MessagingFacades<TMessaging>
|
|
365
375
|
jobs: JobsRuntimeFacade
|
|
366
376
|
realtime: RealtimeFacade<TSchema>
|
|
367
377
|
auth: AuthInstance
|
|
@@ -405,11 +415,18 @@ match.
|
|
|
405
415
|
```ts
|
|
406
416
|
import { provision } from 'bunderstack/provision'
|
|
407
417
|
|
|
408
|
-
await provision(app
|
|
418
|
+
await provision(app)
|
|
409
419
|
```
|
|
410
420
|
|
|
411
|
-
|
|
412
|
-
|
|
421
|
+
The production entrypoint only applies committed migrations and throws an
|
|
422
|
+
actionable error when the journal is absent. Development schema push is
|
|
423
|
+
explicit:
|
|
424
|
+
|
|
425
|
+
```ts
|
|
426
|
+
import { provision } from 'bunderstack/provision-schema'
|
|
427
|
+
|
|
428
|
+
await provision(app, { force: false })
|
|
429
|
+
```
|
|
413
430
|
|
|
414
431
|
## `bunderstack/client`
|
|
415
432
|
|
|
@@ -572,10 +589,8 @@ Bunderstack uses [BetterAuth](https://www.better-auth.com) under the hood. Auth
|
|
|
572
589
|
```ts
|
|
573
590
|
bunderstack({
|
|
574
591
|
schema,
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
secret: process.env.AUTH_SECRET,
|
|
578
|
-
},
|
|
592
|
+
database,
|
|
593
|
+
auth: { emailAndPassword: { enabled: true } },
|
|
579
594
|
})
|
|
580
595
|
```
|
|
581
596
|
|
|
@@ -594,18 +609,21 @@ curl -X POST /api/auth/sign-in/email \
|
|
|
594
609
|
```ts
|
|
595
610
|
bunderstack({
|
|
596
611
|
schema,
|
|
597
|
-
|
|
612
|
+
database,
|
|
613
|
+
env: envSchema,
|
|
614
|
+
// The builder form receives the validated env, and `db` with it.
|
|
615
|
+
auth: ({ env }) => ({
|
|
598
616
|
socialProviders: {
|
|
599
617
|
github: {
|
|
600
|
-
clientId:
|
|
601
|
-
clientSecret:
|
|
618
|
+
clientId: env.GITHUB_CLIENT_ID,
|
|
619
|
+
clientSecret: env.GITHUB_CLIENT_SECRET,
|
|
602
620
|
},
|
|
603
621
|
google: {
|
|
604
|
-
clientId:
|
|
605
|
-
clientSecret:
|
|
622
|
+
clientId: env.GOOGLE_CLIENT_ID,
|
|
623
|
+
clientSecret: env.GOOGLE_CLIENT_SECRET,
|
|
606
624
|
},
|
|
607
625
|
},
|
|
608
|
-
},
|
|
626
|
+
}),
|
|
609
627
|
})
|
|
610
628
|
```
|
|
611
629
|
|
|
@@ -715,7 +733,7 @@ const backend = bunderstack({
|
|
|
715
733
|
input: v.object({ userId: v.string() }),
|
|
716
734
|
retries: 3,
|
|
717
735
|
handler: async ({ userId }, ctx) => {
|
|
718
|
-
// ctx.db, ctx.
|
|
736
|
+
// ctx.db, ctx.messaging, ctx.storage, ctx.jobs, ctx.realtime, ctx.env
|
|
719
737
|
},
|
|
720
738
|
}),
|
|
721
739
|
dailyReport: j.cron({
|
|
@@ -774,6 +792,29 @@ bun run worker
|
|
|
774
792
|
`app.startWorker()` is useful for an explicitly embedded worker;
|
|
775
793
|
`app.runWorker()` owns a dedicated worker process until shutdown.
|
|
776
794
|
|
|
795
|
+
### Concurrency
|
|
796
|
+
|
|
797
|
+
Queue jobs run in a continuous per-type pool. With one worker, omitted
|
|
798
|
+
`concurrency` gives that type 10 execution slots.
|
|
799
|
+
|
|
800
|
+
```ts
|
|
801
|
+
generateAnswer: j.job({
|
|
802
|
+
concurrency: 32,
|
|
803
|
+
timeout: 120_000,
|
|
804
|
+
handler: async (input, ctx) => {},
|
|
805
|
+
})
|
|
806
|
+
```
|
|
807
|
+
|
|
808
|
+
The worker claims rows in internal batches of at most 10 until all 32 slots are
|
|
809
|
+
full. Ten is a database batch size, not a concurrency ceiling. When one handler
|
|
810
|
+
finishes, the worker fills that slot immediately without waiting for the other
|
|
811
|
+
handlers that started beside it.
|
|
812
|
+
|
|
813
|
+
Across several worker processes, the current database-observed capacity check
|
|
814
|
+
is best effort and can race; `concurrency` is not a strict provider-wide
|
|
815
|
+
semaphore. Use an application/provider limiter when several workers share one
|
|
816
|
+
external quota.
|
|
817
|
+
|
|
777
818
|
## Cron
|
|
778
819
|
|
|
779
820
|
`j.cron()` uses a five-field UTC schedule. Each due minute is materialized as a
|
|
@@ -845,7 +886,7 @@ configure a shared Redis transport in both:
|
|
|
845
886
|
```ts
|
|
846
887
|
const backend = bunderstack({
|
|
847
888
|
schema,
|
|
848
|
-
realtime: { redis:
|
|
889
|
+
realtime: (env) => ({ redis: env.REDIS_URL }),
|
|
849
890
|
jobs: (j) =>
|
|
850
891
|
j.define({
|
|
851
892
|
generateImage: j.job({
|
|
@@ -897,7 +938,7 @@ export async function generateResume(
|
|
|
897
938
|
ctx: BunderstackJobContext,
|
|
898
939
|
) {
|
|
899
940
|
const url = await ctx.storage.getUrl(`adaptations/${adaptationId}/resume.pdf`)
|
|
900
|
-
await ctx.email.send({ ... })
|
|
941
|
+
await ctx.messaging.email.send({ ... })
|
|
901
942
|
}
|
|
902
943
|
```
|
|
903
944
|
|
|
@@ -918,18 +959,13 @@ CONFIGURATION
|
|
|
918
959
|
```ts
|
|
919
960
|
const backend = bunderstack({
|
|
920
961
|
schema,
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
url,
|
|
924
|
-
authToken,
|
|
925
|
-
migrations: './migrations',
|
|
926
|
-
},
|
|
962
|
+
env,
|
|
963
|
+
database: { adapter: libsql(), migrations: './migrations' },
|
|
927
964
|
access,
|
|
928
965
|
auth,
|
|
929
966
|
authResolver,
|
|
930
967
|
storage,
|
|
931
|
-
|
|
932
|
-
env,
|
|
968
|
+
messaging,
|
|
933
969
|
jobs: (j) => j.define({}),
|
|
934
970
|
api,
|
|
935
971
|
middleware: [instrumentation],
|
|
@@ -984,17 +1020,19 @@ publishing events that cannot reach another process.
|
|
|
984
1020
|
The adapter dialect must match the Drizzle schema. Import only the adapter you
|
|
985
1021
|
use so optional drivers stay outside the application dependency graph.
|
|
986
1022
|
|
|
987
|
-
##
|
|
1023
|
+
## Messaging and storage adapters
|
|
988
1024
|
|
|
989
|
-
|
|
990
|
-
the
|
|
1025
|
+
`messaging` is a named record of channels. A channel without credentials
|
|
1026
|
+
captures to the message journal instead of sending; see
|
|
1027
|
+
[Messaging](/docs/messaging).
|
|
991
1028
|
|
|
992
1029
|
```ts
|
|
1030
|
+
import { resend } from 'bunderstack'
|
|
993
1031
|
import { smtp } from 'bunderstack/email-smtp'
|
|
994
1032
|
|
|
995
|
-
|
|
996
|
-
from: 'My app <hello@example.com>',
|
|
997
|
-
|
|
1033
|
+
messaging: {
|
|
1034
|
+
email: resend({ apiKey: env.RESEND_API_KEY, from: 'My app <hello@example.com>' }),
|
|
1035
|
+
ops: smtp({ url: env.SMTP_URL, from: 'ops@example.com' }),
|
|
998
1036
|
}
|
|
999
1037
|
```
|
|
1000
1038
|
|
|
@@ -1009,8 +1047,8 @@ and Publisher resources. `app.status`, `app.signal`, and
|
|
|
1009
1047
|
`app.backgroundRunning` expose lifecycle state.
|
|
1010
1048
|
|
|
1011
1049
|
`bunderstack()` is synchronous and side-effect-free. Deployment tooling imports
|
|
1012
|
-
the exported backend and
|
|
1013
|
-
Redis connections. `backend.start({ env })` owns production runtime resources;
|
|
1050
|
+
the exported backend and calls `backend.inspect({ env })`, which resolves the
|
|
1051
|
+
declaration without opening database or Redis connections. `backend.start({ env })` owns production runtime resources;
|
|
1014
1052
|
`backend.test()` creates an isolated, lexically owned test fixture.
|
|
1015
1053
|
|
|
1016
1054
|
For test suites, declare reusable defaults and setup with
|
|
@@ -1383,160 +1421,6 @@ for.
|
|
|
1383
1421
|
The endpoint is public, so results carry a fixed set of codes and never a driver
|
|
1384
1422
|
message, a connection string, or a stack trace.
|
|
1385
1423
|
|
|
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
1424
|
ENVIRONMENT VALIDATION
|
|
1541
1425
|
|
|
1542
1426
|
Bunderstack validates environment values before the application starts.
|
|
@@ -1561,7 +1445,11 @@ export const env = {
|
|
|
1561
1445
|
},
|
|
1562
1446
|
}
|
|
1563
1447
|
|
|
1564
|
-
export const backend = bunderstack({
|
|
1448
|
+
export const backend = bunderstack({
|
|
1449
|
+
schema,
|
|
1450
|
+
env,
|
|
1451
|
+
database,
|
|
1452
|
+
})
|
|
1565
1453
|
export const app = await backend.start()
|
|
1566
1454
|
```
|
|
1567
1455
|
|
|
@@ -1597,6 +1485,48 @@ is an error. Descriptions are static prose, at most 200 characters.
|
|
|
1597
1485
|
Values never reach the blueprint. Only the key name, whether it is required, its
|
|
1598
1486
|
scope, its secrecy, and its description do.
|
|
1599
1487
|
|
|
1488
|
+
## Read validated values inside the declaration
|
|
1489
|
+
|
|
1490
|
+
The declaration stays one object. The slots that hold credentials —
|
|
1491
|
+
`database`, `storage`, `messaging`, and `realtime` — also accept a function of
|
|
1492
|
+
the validated environment, and `auth` has taken a builder over `{ db, env }`
|
|
1493
|
+
since 0.22. Nothing else needs the environment: a procedure or a job reads
|
|
1494
|
+
`ctx.env`.
|
|
1495
|
+
|
|
1496
|
+
```ts
|
|
1497
|
+
export const backend = bunderstack({
|
|
1498
|
+
schema,
|
|
1499
|
+
env,
|
|
1500
|
+
database: { adapter: libsql() }, // url comes from DATABASE_URL
|
|
1501
|
+
auth: ({ env }) => ({ baseURL: env.PUBLIC_APP_URL }),
|
|
1502
|
+
messaging: (env) => ({ email: resend({ apiKey: env.RESEND_API_KEY }) }),
|
|
1503
|
+
})
|
|
1504
|
+
```
|
|
1505
|
+
|
|
1506
|
+
Those functions are pure and may run more than once — once per `inspect()`,
|
|
1507
|
+
once per `start()`, once per test fixture — so they must not open connections,
|
|
1508
|
+
read files, or hold state. Two fixtures with different values stay independent
|
|
1509
|
+
because each resolves the declaration for itself.
|
|
1510
|
+
|
|
1511
|
+
A slot supplies a value, never a shape. `backend.inspect({ env })` resolves the
|
|
1512
|
+
declaration and returns its manifest without any I/O, and blueprint generation
|
|
1513
|
+
resolves it against two probe environments and rejects a declaration whose
|
|
1514
|
+
_shape_ differs between them:
|
|
1515
|
+
|
|
1516
|
+
```ts
|
|
1517
|
+
// Rejected: the blueprint would differ per environment.
|
|
1518
|
+
bunderstack({
|
|
1519
|
+
schema,
|
|
1520
|
+
env,
|
|
1521
|
+
database,
|
|
1522
|
+
realtime: (env) => env.FEATURE_FLAG === 'enabled',
|
|
1523
|
+
})
|
|
1524
|
+
```
|
|
1525
|
+
|
|
1526
|
+
The error names the differing path, such as `realtime.required`, and never the
|
|
1527
|
+
probe values. That check is what lets a host provision from the committed
|
|
1528
|
+
blueprint alone, before any value exists.
|
|
1529
|
+
|
|
1600
1530
|
## Use validated values
|
|
1601
1531
|
|
|
1602
1532
|
```ts
|
|
@@ -1627,7 +1557,7 @@ export const clientEnv = createClientEnv({
|
|
|
1627
1557
|
|
|
1628
1558
|
## Built-in values
|
|
1629
1559
|
|
|
1630
|
-
Bunderstack also understands database, auth, Redis,
|
|
1560
|
+
Bunderstack also understands database, auth, Redis, messaging, and storage
|
|
1631
1561
|
variables used by its own facilities. Production requires a secure
|
|
1632
1562
|
`AUTH_SECRET`. Database URLs have adapter-specific development defaults; Redis
|
|
1633
1563
|
is required when separate worker processes publish realtime changes.
|
|
@@ -1636,7 +1566,11 @@ is required when separate worker processes publish realtime changes.
|
|
|
1636
1566
|
import { BunderstackEnvError } from 'bunderstack'
|
|
1637
1567
|
|
|
1638
1568
|
try {
|
|
1639
|
-
await bunderstack({
|
|
1569
|
+
await bunderstack({
|
|
1570
|
+
schema,
|
|
1571
|
+
env,
|
|
1572
|
+
database,
|
|
1573
|
+
}).start()
|
|
1640
1574
|
} catch (error) {
|
|
1641
1575
|
if (error instanceof BunderstackEnvError) {
|
|
1642
1576
|
console.error(error.issues)
|
|
@@ -1650,6 +1584,11 @@ FRAMEWORK PORTABILITY
|
|
|
1650
1584
|
|
|
1651
1585
|
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
1586
|
|
|
1587
|
+
The examples use `bunderstack/provision`, which only applies committed
|
|
1588
|
+
migrations and keeps Drizzle Kit out of production bundles. Generate and commit
|
|
1589
|
+
the migration journal before starting them. For local schema push, import from
|
|
1590
|
+
`bunderstack/provision-schema` instead.
|
|
1591
|
+
|
|
1653
1592
|
---
|
|
1654
1593
|
|
|
1655
1594
|
## TanStack Start (Full-Stack SSR)
|
|
@@ -1677,10 +1616,7 @@ import * as schema from './schema'
|
|
|
1677
1616
|
export const backend = bunderstack({
|
|
1678
1617
|
schema,
|
|
1679
1618
|
access,
|
|
1680
|
-
database: {
|
|
1681
|
-
adapter: libsql(),
|
|
1682
|
-
url: process.env.DATABASE_URL || 'file:./data.db',
|
|
1683
|
-
},
|
|
1619
|
+
database: { adapter: libsql() },
|
|
1684
1620
|
auth: { emailAndPassword: { enabled: true } },
|
|
1685
1621
|
realtime: true,
|
|
1686
1622
|
})
|
|
@@ -1754,10 +1690,7 @@ import * as schema from './schema'
|
|
|
1754
1690
|
|
|
1755
1691
|
export const backend = bunderstack({
|
|
1756
1692
|
schema,
|
|
1757
|
-
database: {
|
|
1758
|
-
adapter: libsql(),
|
|
1759
|
-
url: process.env.DATABASE_URL || 'file:./data.db',
|
|
1760
|
-
},
|
|
1693
|
+
database: { adapter: libsql() },
|
|
1761
1694
|
auth: { emailAndPassword: { enabled: true } },
|
|
1762
1695
|
realtime: true,
|
|
1763
1696
|
})
|
|
@@ -1863,10 +1796,7 @@ import * as schema from './schema'
|
|
|
1863
1796
|
|
|
1864
1797
|
export const backend = bunderstack({
|
|
1865
1798
|
schema,
|
|
1866
|
-
database: {
|
|
1867
|
-
adapter: libsql(),
|
|
1868
|
-
url: process.env.DATABASE_URL || 'file:./data.db',
|
|
1869
|
-
},
|
|
1799
|
+
database: { adapter: libsql() },
|
|
1870
1800
|
auth: { emailAndPassword: { enabled: true } },
|
|
1871
1801
|
realtime: true,
|
|
1872
1802
|
})
|
|
@@ -1941,10 +1871,7 @@ import * as schema from './schema'
|
|
|
1941
1871
|
|
|
1942
1872
|
export const backend = bunderstack({
|
|
1943
1873
|
schema,
|
|
1944
|
-
database: {
|
|
1945
|
-
adapter: libsql(),
|
|
1946
|
-
url: process.env.DATABASE_URL || 'file:./data.db',
|
|
1947
|
-
},
|
|
1874
|
+
database: { adapter: libsql() },
|
|
1948
1875
|
auth: { emailAndPassword: { enabled: true } },
|
|
1949
1876
|
realtime: true,
|
|
1950
1877
|
})
|
|
@@ -2129,7 +2056,7 @@ contract automatically.
|
|
|
2129
2056
|
// bunderstack.ts
|
|
2130
2057
|
import { bunderstack } from 'bunderstack'
|
|
2131
2058
|
import { libsql } from 'bunderstack/libsql'
|
|
2132
|
-
import { provision } from 'bunderstack/provision'
|
|
2059
|
+
import { provision } from 'bunderstack/provision-schema'
|
|
2133
2060
|
import { count } from 'drizzle-orm'
|
|
2134
2061
|
import * as v from 'valibot'
|
|
2135
2062
|
|
|
@@ -2138,7 +2065,7 @@ import * as schema from './schema'
|
|
|
2138
2065
|
|
|
2139
2066
|
export const backend = bunderstack({
|
|
2140
2067
|
schema,
|
|
2141
|
-
database: { adapter: libsql()
|
|
2068
|
+
database: { adapter: libsql() },
|
|
2142
2069
|
access: {
|
|
2143
2070
|
posts: {
|
|
2144
2071
|
ownerColumn: 'userId',
|
|
@@ -2164,10 +2091,18 @@ export type App = typeof app
|
|
|
2164
2091
|
await provision(app)
|
|
2165
2092
|
```
|
|
2166
2093
|
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2094
|
+
The declaration is one object and it is pure: nothing connects until
|
|
2095
|
+
`backend.start()`. `database.url` is omitted here because it defaults to
|
|
2096
|
+
`DATABASE_URL`. When a slot does need a value — an API key, a custom URL —
|
|
2097
|
+
`database`, `storage`, `messaging`, and `realtime` also accept a function of
|
|
2098
|
+
the validated environment, and `auth` accepts a builder over `{ db, env }`.
|
|
2099
|
+
|
|
2100
|
+
This getting-started flow uses the development-only schema push from
|
|
2101
|
+
`bunderstack/provision-schema`, which requires Drizzle Kit. Before deployment,
|
|
2102
|
+
run `bunx drizzle-kit generate`, commit the journal, and switch the import to
|
|
2103
|
+
`bunderstack/provision`. The production entrypoint only applies committed
|
|
2104
|
+
migrations and never imports Drizzle Kit. Skip it when deployment manages
|
|
2105
|
+
migrations outside the application.
|
|
2171
2106
|
|
|
2172
2107
|
## Serve one handler
|
|
2173
2108
|
|
|
@@ -2337,11 +2272,12 @@ There is no general-purpose router to configure alongside them.
|
|
|
2337
2272
|
## Batteries, without a platform
|
|
2338
2273
|
|
|
2339
2274
|
Bunderstack includes database provisioning, Better Auth, access-controlled
|
|
2340
|
-
CRUD, file storage and transforms,
|
|
2275
|
+
CRUD, file storage and transforms, messaging channels, validated environment
|
|
2276
|
+
variables,
|
|
2341
2277
|
background jobs, rate limiting, idempotency, and realtime publication.
|
|
2342
2278
|
|
|
2343
2279
|
It remains a library inside your application. `app.db` is Drizzle,
|
|
2344
|
-
`app.auth` is Better Auth, and `app.storage`, `app.
|
|
2280
|
+
`app.auth` is Better Auth, and `app.storage`, `app.messaging`, `app.jobs`, and
|
|
2345
2281
|
`app.realtime` are available in every procedure context. Your schema and data
|
|
2346
2282
|
stay in your repository and infrastructure.
|
|
2347
2283
|
|
|
@@ -2355,6 +2291,252 @@ Start with [Getting Started](/docs/getting-started), then follow the primary
|
|
|
2355
2291
|
path through [Auto CRUD](/docs/crud), [API Procedures](/docs/api-procedures),
|
|
2356
2292
|
[Query Client](/docs/query-client), and [Sync & Realtime](/docs/sync-collections).
|
|
2357
2293
|
|
|
2294
|
+
MESSAGING
|
|
2295
|
+
|
|
2296
|
+
`messaging` is a named record of channels. Each channel names one provider, and
|
|
2297
|
+
each provider carries its own message type. Send through
|
|
2298
|
+
`app.messaging.<channel>.send()` on the server, or `ctx.messaging.<channel>`
|
|
2299
|
+
inside a procedure or a job.
|
|
2300
|
+
|
|
2301
|
+
## Declaration
|
|
2302
|
+
|
|
2303
|
+
```ts
|
|
2304
|
+
import { bunderstack, resend, telegram } from 'bunderstack'
|
|
2305
|
+
import { libsql } from 'bunderstack/libsql'
|
|
2306
|
+
import * as v from 'valibot'
|
|
2307
|
+
|
|
2308
|
+
import * as schema from './schema'
|
|
2309
|
+
|
|
2310
|
+
export const backend = bunderstack({
|
|
2311
|
+
schema,
|
|
2312
|
+
env: {
|
|
2313
|
+
server: {
|
|
2314
|
+
RESEND_API_KEY: v.optional(v.string()),
|
|
2315
|
+
TELEGRAM_BOT_TOKEN: v.optional(v.string()),
|
|
2316
|
+
},
|
|
2317
|
+
},
|
|
2318
|
+
database: { adapter: libsql() },
|
|
2319
|
+
// `messaging` holds credentials, so it also accepts a function of the
|
|
2320
|
+
// validated environment. Which channels exist must not depend on a value.
|
|
2321
|
+
messaging: (env) => ({
|
|
2322
|
+
email: resend({ apiKey: env.RESEND_API_KEY, from: 'app@example.com' }),
|
|
2323
|
+
billing: resend({
|
|
2324
|
+
apiKey: env.RESEND_API_KEY,
|
|
2325
|
+
from: 'billing@example.com',
|
|
2326
|
+
}),
|
|
2327
|
+
ops: telegram({ botToken: env.TELEGRAM_BOT_TOKEN }),
|
|
2328
|
+
}),
|
|
2329
|
+
})
|
|
2330
|
+
|
|
2331
|
+
export const app = await backend.start()
|
|
2332
|
+
```
|
|
2333
|
+
|
|
2334
|
+
Two channels can name the same provider with a different sender. Only the
|
|
2335
|
+
channel names and provider kinds reach the manifest and the blueprint; a key,
|
|
2336
|
+
a token, and a sender address never do.
|
|
2337
|
+
|
|
2338
|
+
## Sending
|
|
2339
|
+
|
|
2340
|
+
An email channel takes an email message:
|
|
2341
|
+
|
|
2342
|
+
```ts
|
|
2343
|
+
await app.messaging.email.send({
|
|
2344
|
+
to: 'user@example.com',
|
|
2345
|
+
subject: 'Welcome',
|
|
2346
|
+
html: '<h1>Hello</h1>',
|
|
2347
|
+
text: 'Hello',
|
|
2348
|
+
})
|
|
2349
|
+
```
|
|
2350
|
+
|
|
2351
|
+
A message needs `html` or `text`. Everything else except `to` and `subject` is
|
|
2352
|
+
optional, and a per-message `from` overrides the channel's own sender.
|
|
2353
|
+
|
|
2354
|
+
```ts
|
|
2355
|
+
await app.messaging.billing.send({
|
|
2356
|
+
to: ['alice@a.com', 'bob@b.com'],
|
|
2357
|
+
subject: 'Invoice 41',
|
|
2358
|
+
text: 'Attached.',
|
|
2359
|
+
replyTo: 'support@example.com',
|
|
2360
|
+
cc: 'manager@example.com',
|
|
2361
|
+
bcc: 'archive@example.com',
|
|
2362
|
+
})
|
|
2363
|
+
```
|
|
2364
|
+
|
|
2365
|
+
A Telegram channel takes a different message, and TypeScript enforces it:
|
|
2366
|
+
|
|
2367
|
+
```ts
|
|
2368
|
+
await app.messaging.ops.send({
|
|
2369
|
+
to: '@release_channel',
|
|
2370
|
+
text: '*Deploy finished*',
|
|
2371
|
+
parseMode: 'MarkdownV2',
|
|
2372
|
+
})
|
|
2373
|
+
```
|
|
2374
|
+
|
|
2375
|
+
`send()` returns `{ id, providerId? }`. `id` is the journal row; `providerId`
|
|
2376
|
+
is the provider's own identifier when the provider returns one.
|
|
2377
|
+
|
|
2378
|
+
## Capture
|
|
2379
|
+
|
|
2380
|
+
A channel whose required configuration is absent or empty captures instead of
|
|
2381
|
+
sending. Capture is not an error state and not a separate provider: every
|
|
2382
|
+
channel has it.
|
|
2383
|
+
|
|
2384
|
+
- Locally, a captured message is written to the journal and printed to the
|
|
2385
|
+
console.
|
|
2386
|
+
- On a host (`BUNDERHOST_ENVIRONMENT_ID` is set), it is written to the journal
|
|
2387
|
+
only. The body never reaches the production logs.
|
|
2388
|
+
|
|
2389
|
+
A key that is present but wrong is not capture. So is a provider that rejects
|
|
2390
|
+
the request: both raise, and the journal row records `failed`.
|
|
2391
|
+
|
|
2392
|
+
For Resend that means `apiKey` and `from`, for Telegram `botToken`, and for a
|
|
2393
|
+
custom adapter the adapter itself plus `from`.
|
|
2394
|
+
|
|
2395
|
+
## Providers
|
|
2396
|
+
|
|
2397
|
+
### Resend
|
|
2398
|
+
|
|
2399
|
+
```ts
|
|
2400
|
+
messaging: (env) => ({
|
|
2401
|
+
email: resend({ apiKey: env.RESEND_API_KEY, from: 'app@example.com' }),
|
|
2402
|
+
})
|
|
2403
|
+
```
|
|
2404
|
+
|
|
2405
|
+
### SMTP
|
|
2406
|
+
|
|
2407
|
+
```ts
|
|
2408
|
+
import { smtp } from 'bunderstack/email-smtp'
|
|
2409
|
+
|
|
2410
|
+
messaging: (env) => ({
|
|
2411
|
+
email: smtp({ url: env.SMTP_URL, from: 'app@example.com' }),
|
|
2412
|
+
})
|
|
2413
|
+
```
|
|
2414
|
+
|
|
2415
|
+
SMTP uses [nodemailer](https://nodemailer.com) through its own subpath, so an
|
|
2416
|
+
application that does not declare it never loads it:
|
|
2417
|
+
|
|
2418
|
+
```bash
|
|
2419
|
+
bun add nodemailer
|
|
2420
|
+
```
|
|
2421
|
+
|
|
2422
|
+
### Telegram
|
|
2423
|
+
|
|
2424
|
+
```ts
|
|
2425
|
+
messaging: (env) => ({
|
|
2426
|
+
ops: telegram({ botToken: env.TELEGRAM_BOT_TOKEN }),
|
|
2427
|
+
})
|
|
2428
|
+
```
|
|
2429
|
+
|
|
2430
|
+
`to` accepts a chat ID or an `@channel` name.
|
|
2431
|
+
|
|
2432
|
+
### Custom email
|
|
2433
|
+
|
|
2434
|
+
Pass a full adapter or one `send` function:
|
|
2435
|
+
|
|
2436
|
+
```ts
|
|
2437
|
+
import { customEmail } from 'bunderstack'
|
|
2438
|
+
|
|
2439
|
+
messaging: {
|
|
2440
|
+
email: customEmail({
|
|
2441
|
+
from: 'app@example.com',
|
|
2442
|
+
adapter: async (message) => {
|
|
2443
|
+
// `from` is already resolved on the message.
|
|
2444
|
+
await mySendService(message)
|
|
2445
|
+
return { id: 'msg_123' }
|
|
2446
|
+
},
|
|
2447
|
+
}),
|
|
2448
|
+
}
|
|
2449
|
+
```
|
|
2450
|
+
|
|
2451
|
+
## Managed credentials
|
|
2452
|
+
|
|
2453
|
+
A host can supply credentials through the reserved
|
|
2454
|
+
`BUNDERSTACK_MESSAGING_CONFIG` variable, a JSON object keyed by provider:
|
|
2455
|
+
|
|
2456
|
+
```json
|
|
2457
|
+
{ "resend": { "apiKey": "re_managed" }, "telegram": { "botToken": "123:abc" } }
|
|
2458
|
+
```
|
|
2459
|
+
|
|
2460
|
+
Every channel of one provider shares that connection. A field the channel
|
|
2461
|
+
declares itself wins over the managed value, field by field, so a channel can
|
|
2462
|
+
take the managed key and keep its own `from`. Each journal row records where
|
|
2463
|
+
the credentials came from: `explicit`, `managed`, or `capture`.
|
|
2464
|
+
|
|
2465
|
+
## Auth emails
|
|
2466
|
+
|
|
2467
|
+
Better Auth verification and password-reset mail goes through the channel named
|
|
2468
|
+
`email`, and only that one, when its provider is an email provider. A channel
|
|
2469
|
+
named anything else is never selected for it.
|
|
2470
|
+
|
|
2471
|
+
## The journal
|
|
2472
|
+
|
|
2473
|
+
Every channel writes to `_bunderstack_messages`, with delivery attempts in
|
|
2474
|
+
`_bunderstack_message_events`. A row carries the channel, the provider kind, the
|
|
2475
|
+
credential source, the status (`captured`, `sending`, `sent`, or `failed`), the
|
|
2476
|
+
recipients, the content, and the provider's message ID once it exists.
|
|
2477
|
+
|
|
2478
|
+
## Testing
|
|
2479
|
+
|
|
2480
|
+
A test fixture substitutes an isolated capture for every declared channel, so
|
|
2481
|
+
nothing leaves the process and no fixture sees another fixture's messages:
|
|
2482
|
+
|
|
2483
|
+
```ts
|
|
2484
|
+
await using t = await backend.test()
|
|
2485
|
+
|
|
2486
|
+
await t.app.messaging.email.send({
|
|
2487
|
+
to: 'user@example.com',
|
|
2488
|
+
subject: 'Welcome',
|
|
2489
|
+
text: 'Hello',
|
|
2490
|
+
})
|
|
2491
|
+
|
|
2492
|
+
expect(t.messaging.email.sent).toHaveLength(1)
|
|
2493
|
+
expect(t.messaging.ops.sent).toEqual([])
|
|
2494
|
+
```
|
|
2495
|
+
|
|
2496
|
+
Each `sent` array is typed by its own channel, so a Telegram capture never
|
|
2497
|
+
type-checks against an email message.
|
|
2498
|
+
|
|
2499
|
+
## Sending outside the app
|
|
2500
|
+
|
|
2501
|
+
Code that runs before or beside the started app — a Better Auth builder sending
|
|
2502
|
+
an invitation, a script, a one-off worker — builds the same facades with
|
|
2503
|
+
`createMessaging`:
|
|
2504
|
+
|
|
2505
|
+
```ts
|
|
2506
|
+
import { createMessaging, defineAuth, resend } from 'bunderstack'
|
|
2507
|
+
|
|
2508
|
+
export const auth = defineAuth(schema, ({ db, env }) => {
|
|
2509
|
+
const messaging = createMessaging(
|
|
2510
|
+
{ email: resend({ apiKey: env.RESEND_API_KEY, from: env.EMAIL_FROM }) },
|
|
2511
|
+
{ env, db },
|
|
2512
|
+
)
|
|
2513
|
+
return {
|
|
2514
|
+
/* … */
|
|
2515
|
+
}
|
|
2516
|
+
})
|
|
2517
|
+
```
|
|
2518
|
+
|
|
2519
|
+
Pass `db` so those messages reach the same journal the application writes.
|
|
2520
|
+
Capture, managed credentials, and provider errors behave exactly as they do on
|
|
2521
|
+
`app.messaging`.
|
|
2522
|
+
|
|
2523
|
+
## Exact channel types in a separate module
|
|
2524
|
+
|
|
2525
|
+
An inline `jobs` or `api` builder receives an open messaging type, because
|
|
2526
|
+
TypeScript cannot infer the channel record and type a sibling callback in the
|
|
2527
|
+
same pass. Declare the builder in its own module to get the exact type:
|
|
2528
|
+
|
|
2529
|
+
```ts
|
|
2530
|
+
import type { BunderstackJobsBuilder } from 'bunderstack'
|
|
2531
|
+
import type { ResendDescriptor } from 'bunderstack/messaging'
|
|
2532
|
+
|
|
2533
|
+
export const defineJobs = (
|
|
2534
|
+
jobs: BunderstackJobsBuilder<typeof schema, AppEnv, { email: ResendDescriptor }>,
|
|
2535
|
+
) => jobs.define({ ... })
|
|
2536
|
+
```
|
|
2537
|
+
|
|
2538
|
+
`defineApi({ schema, env, messaging })` does the same for procedures.
|
|
2539
|
+
|
|
2358
2540
|
MIDDLEWARE
|
|
2359
2541
|
|
|
2360
2542
|
A middleware wraps a procedure call. It sees the request before the handler,
|
|
@@ -2480,7 +2662,11 @@ bun add bunderstack @tanstack/react-query
|
|
|
2480
2662
|
|
|
2481
2663
|
```ts
|
|
2482
2664
|
// bunderstack.ts — server
|
|
2483
|
-
export const backend = bunderstack({
|
|
2665
|
+
export const backend = bunderstack({
|
|
2666
|
+
schema,
|
|
2667
|
+
access,
|
|
2668
|
+
api: (o) => ({}),
|
|
2669
|
+
})
|
|
2484
2670
|
export const app = await backend.start()
|
|
2485
2671
|
export type App = typeof app
|
|
2486
2672
|
```
|
|
@@ -2655,13 +2841,17 @@ bunderstack({
|
|
|
2655
2841
|
## Local storage
|
|
2656
2842
|
|
|
2657
2843
|
```ts
|
|
2658
|
-
bunderstack({
|
|
2844
|
+
bunderstack({
|
|
2845
|
+
schema,
|
|
2846
|
+
storage: { local: './uploads' },
|
|
2847
|
+
})
|
|
2659
2848
|
```
|
|
2660
2849
|
|
|
2661
2850
|
## S3 / R2 / MinIO
|
|
2662
2851
|
|
|
2663
2852
|
```ts
|
|
2664
|
-
bunderstack({
|
|
2853
|
+
bunderstack({
|
|
2854
|
+
schema, storage: { s3: true } })
|
|
2665
2855
|
# Set S3_BUCKET, S3_REGION, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY in .env
|
|
2666
2856
|
# For R2/MinIO also set S3_ENDPOINT
|
|
2667
2857
|
```
|