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.
Files changed (41) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +1 -0
  3. package/dist/auth.d.ts +20 -9
  4. package/dist/auth.d.ts.map +1 -1
  5. package/dist/auth.js +3 -8
  6. package/dist/auth.js.map +1 -1
  7. package/dist/backend.d.ts +4 -4
  8. package/dist/backend.d.ts.map +1 -1
  9. package/dist/backend.js.map +1 -1
  10. package/dist/config.d.ts +10 -6
  11. package/dist/config.d.ts.map +1 -1
  12. package/dist/config.js.map +1 -1
  13. package/dist/provision-internals.d.ts +1 -1
  14. package/dist/provision-internals.js +1 -1
  15. package/dist/provision-internals.js.map +1 -1
  16. package/dist/provision-runtime.d.ts +7 -0
  17. package/dist/provision-runtime.d.ts.map +1 -0
  18. package/dist/provision-runtime.js +50 -0
  19. package/dist/provision-runtime.js.map +1 -0
  20. package/dist/provision-schema.d.ts +16 -0
  21. package/dist/provision-schema.d.ts.map +1 -0
  22. package/dist/provision-schema.js +63 -0
  23. package/dist/provision-schema.js.map +1 -0
  24. package/dist/provision.d.ts +4 -21
  25. package/dist/provision.d.ts.map +1 -1
  26. package/dist/provision.js +10 -111
  27. package/dist/provision.js.map +1 -1
  28. package/dist/runtime.d.ts +9 -7
  29. package/dist/runtime.d.ts.map +1 -1
  30. package/dist/runtime.js +2 -3
  31. package/dist/runtime.js.map +1 -1
  32. package/dist/testing/fixture.d.ts +1 -1
  33. package/dist/testing/fixture.d.ts.map +1 -1
  34. package/dist/testing/fixture.js +1 -1
  35. package/dist/testing/fixture.js.map +1 -1
  36. package/llms-full.txt +416 -226
  37. package/llms.txt +4 -3
  38. package/package.json +5 -1
  39. package/skills/creating-bunderstack-apps/references/verification.md +7 -6
  40. package/skills/migrating-to-bunderstack/SKILL.md +22 -20
  41. 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
- - `storage`, `email`, `env`, and `jobs`: application facilities;
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. `backend.manifest` is available
311
- synchronously for Blueprint generation. Call `await backend.start({ env })` to
312
- materialize a production runtime, or `await backend.test()` to create an
313
- isolated test fixture owned by the current lexical scope.
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
- email: EmailFacade
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 `TEnv` from the values it receives, so an
352
- application never writes `BunderstackApiBuilder<…>` by hand. It reads nothing
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
- email: EmailFacade
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, { force: false })
418
+ await provision(app)
409
419
  ```
410
420
 
411
- Without a migration journal, provisioning uses the development schema push.
412
- With committed migrations, it applies pending migrations.
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
- auth: {
576
- emailAndPassword: { enabled: true },
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
- auth: {
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: process.env.GITHUB_CLIENT_ID!,
601
- clientSecret: process.env.GITHUB_CLIENT_SECRET!,
618
+ clientId: env.GITHUB_CLIENT_ID,
619
+ clientSecret: env.GITHUB_CLIENT_SECRET,
602
620
  },
603
621
  google: {
604
- clientId: process.env.GOOGLE_CLIENT_ID!,
605
- clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
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.email, ctx.storage, ctx.jobs, ctx.realtime, ctx.env
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: process.env.REDIS_URL! },
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
- database: {
922
- adapter: libsql(),
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
- email,
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
- ## Email and storage adapters
1023
+ ## Messaging and storage adapters
988
1024
 
989
- Email defaults to the console provider in development. Use Resend directly or
990
- the optional SMTP adapter:
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
- email: {
996
- from: 'My app <hello@example.com>',
997
- provider: smtp({ url: process.env.SMTP_URL! }),
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 reads `backend.manifest` without opening database or
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({ schema, database, env })
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, email, and storage
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({ schema, database, env }).start()
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(), url: 'file:./data.db' },
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
- `provision(app)` pushes the schema while there is no migrations journal. Once
2168
- you run `bunx drizzle-kit generate` and commit the migrations, the same call
2169
- only applies pending migrations. Skip it when deployment manages migrations
2170
- outside the application.
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, email, validated environment variables,
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.email`, `app.jobs`, and
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({ schema, access, api: (o) => ({}) })
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({ schema, storage: { local: './uploads' } })
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({ schema, storage: { s3: true } })
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
  ```