bunderstack 0.15.2 → 0.17.0-beta.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/README.md +25 -138
- package/package.json +22 -14
- package/src/access.ts +24 -1
- package/src/api/api-types.types.ts +106 -0
- package/src/api/builder.ts +52 -0
- package/src/api/context.ts +83 -0
- package/src/api/crud-router.ts +321 -0
- package/src/api/openapi.ts +184 -0
- package/src/api/realtime-router.ts +75 -0
- package/src/api/registry.ts +338 -0
- package/src/api/router.ts +34 -0
- package/src/api/storage-router.ts +224 -0
- package/src/api/types.ts +84 -0
- package/src/auth.ts +5 -0
- package/src/blueprint.ts +88 -105
- package/src/config.ts +73 -77
- package/src/cron.ts +2 -1
- package/src/crud-operations.ts +488 -0
- package/src/dialect.ts +1 -1
- package/src/env.ts +28 -21
- package/src/errors.ts +90 -23
- package/src/handler.ts +16 -44
- package/src/index.ts +283 -294
- package/src/internal-tables-pg.ts +1 -17
- package/src/internal-tables.ts +0 -31
- package/src/jobs/define.ts +75 -21
- package/src/jobs/index.ts +3 -9
- package/src/jobs/queue.ts +14 -6
- package/src/jobs/slots.ts +52 -0
- package/src/jobs/worker.ts +142 -42
- package/src/manifest.ts +84 -93
- package/src/realtime/facade.ts +16 -13
- package/src/realtime/filter.ts +77 -0
- package/src/realtime/heartbeat.ts +80 -0
- package/src/realtime/publisher.ts +46 -0
- package/src/standard-schema.ts +59 -0
- package/src/storage/index.ts +8 -0
- package/src/storage/operations.ts +398 -0
- package/src/crud.ts +0 -408
- package/src/jobs/cron-auth.ts +0 -28
- package/src/jobs/cron-router.ts +0 -135
- package/src/jobs/cron-runner.ts +0 -224
- package/src/jobs/local-cron.ts +0 -78
- package/src/realtime/index.ts +0 -250
- package/src/realtime/redis.ts +0 -228
- package/src/storage/router.ts +0 -531
- package/src/trpc.ts +0 -57
package/README.md
CHANGED
|
@@ -1,164 +1,51 @@
|
|
|
1
1
|
# bunderstack
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
and get CRUD APIs, auth, file storage, realtime, typed custom endpoints
|
|
5
|
-
(tRPC), email, and validated env — all from a single config object and a
|
|
6
|
-
single `Request → Response` handler.
|
|
3
|
+
The server package for Bunderstack's unified, type-safe oRPC backend.
|
|
7
4
|
|
|
8
5
|
```sh
|
|
9
|
-
bun add bunderstack better-auth drizzle-orm
|
|
6
|
+
bun add bunderstack better-auth drizzle-orm valibot @libsql/client
|
|
10
7
|
```
|
|
11
8
|
|
|
12
9
|
```ts
|
|
13
10
|
import { createBunderstack } from 'bunderstack'
|
|
14
11
|
import { libsql } from 'bunderstack/database/libsql'
|
|
12
|
+
import * as v from 'valibot'
|
|
15
13
|
import * as schema from './schema'
|
|
16
14
|
|
|
17
|
-
const app = await createBunderstack({
|
|
15
|
+
export const app = await createBunderstack({
|
|
18
16
|
schema,
|
|
19
|
-
database: {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
17
|
+
database: { adapter: libsql(), url: 'file:./data.db' },
|
|
18
|
+
access: { posts: { crud: true } },
|
|
19
|
+
realtime: true,
|
|
20
|
+
api: (o) => ({
|
|
21
|
+
ping: o.public
|
|
22
|
+
.route({ method: 'GET', path: '/api/ping' })
|
|
23
|
+
.input(v.optional(v.object({})))
|
|
24
|
+
.handler(() => ({ ok: true })),
|
|
25
|
+
}),
|
|
27
26
|
})
|
|
28
27
|
|
|
29
28
|
Bun.serve({ fetch: app.handler })
|
|
29
|
+
export type App = typeof app
|
|
30
30
|
```
|
|
31
31
|
|
|
32
|
-
|
|
33
|
-
|
|
32
|
+
CRUD, custom procedures, webhooks, file buckets, health, and the Publisher
|
|
33
|
+
event iterator form one router. Validation accepts Standard Schema; internal
|
|
34
|
+
generated schemas use Valibot. OpenAPI is opt-in with `openapi: true`.
|
|
34
35
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
Deployment platforms (like Bunderhost) integrate with any bunderstack app
|
|
38
|
-
through env vars alone — no code changes required.
|
|
39
|
-
|
|
40
|
-
### Overrides (beat code-level config)
|
|
41
|
-
|
|
42
|
-
| Var | Effect |
|
|
43
|
-
| ------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- |
|
|
44
|
-
| `BUNDERSTACK_DATABASE_URL` | Database URL; wins over `database.url` in code |
|
|
45
|
-
| `BUNDERSTACK_DATABASE_AUTH_TOKEN` | Auth token for the database |
|
|
46
|
-
| `BUNDERSTACK_S3_ENDPOINT` | Forces ALL buckets onto this S3 backend (code-level `local`/per-bucket `s3` blocks are ignored) |
|
|
47
|
-
| `BUNDERSTACK_S3_BUCKET` | Physical bucket name (logical buckets become key prefixes) |
|
|
48
|
-
| `BUNDERSTACK_S3_ACCESS_KEY_ID` / `BUNDERSTACK_S3_SECRET_ACCESS_KEY` | Credentials |
|
|
49
|
-
| `BUNDERSTACK_S3_REGION` | Region (default `auto`) |
|
|
50
|
-
| `BUNDERSTACK_S3_PUBLIC_URL` | Public base URL for `visibility: 'public'` buckets |
|
|
51
|
-
|
|
52
|
-
Plain `DATABASE_URL` / `S3_*` vars keep their usual role: fallbacks that
|
|
53
|
-
code-level config wins over.
|
|
54
|
-
|
|
55
|
-
### Committed deployment blueprint
|
|
56
|
-
|
|
57
|
-
Generate a deterministic, provider-neutral declaration that a host can read
|
|
58
|
-
without importing the application at deploy time. Version 1 supports TanStack
|
|
59
|
-
Start apps and records database tables and migration mode, storage buckets,
|
|
60
|
-
environment requirements, realtime, jobs, cron, and maintenance schedules.
|
|
61
|
-
|
|
62
|
-
```sh
|
|
63
|
-
bunx bunderstack blueprint
|
|
64
|
-
bunx bunderstack blueprint --check
|
|
65
|
-
```
|
|
66
|
-
|
|
67
|
-
The command imports `src/bunderstack.ts` by default (or
|
|
68
|
-
`package.json#bunderstack.entry`) with `BUNDERSTACK_INTROSPECT=1`, so it never
|
|
69
|
-
opens external database, storage, or realtime connections. Commit the generated
|
|
70
|
-
`bunderstack.blueprint.yaml`; CI should run the `--check` form. The public
|
|
71
|
-
`bunderstack/blueprint` module exposes the strict parser and conversion helpers
|
|
72
|
-
for hosts and other tooling.
|
|
73
|
-
|
|
74
|
-
Real database clients belong to the app. Call `await app.close()` when a
|
|
75
|
-
standalone process or test is finished; it closes the real libSQL, PGlite,
|
|
76
|
-
postgres.js, or Bun SQL client selected by `database.adapter`. Introspection
|
|
77
|
-
mocks own no client, so there is nothing to close for that database path.
|
|
78
|
-
|
|
79
|
-
### Background runtime
|
|
80
|
-
|
|
81
|
-
Declaring jobs does not start a worker. Queue jobs (`j.job()`) are processed by
|
|
82
|
-
an explicit worker process:
|
|
36
|
+
Generated CRUD writes publish automatically. After a custom write commits,
|
|
37
|
+
publish its complete returned row with:
|
|
83
38
|
|
|
84
39
|
```ts
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
await app.runWorker()
|
|
40
|
+
await context.realtime.publish(schema.posts, 'update', post)
|
|
88
41
|
```
|
|
89
42
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
process-local memory broker and is suitable only when the worker is embedded
|
|
94
|
-
with `app.startWorker()`.
|
|
43
|
+
Use the in-memory Publisher for one process or configure
|
|
44
|
+
`realtime: { redis: process.env.REDIS_URL! }` for multi-process delivery and
|
|
45
|
+
replay. Deployment metadata is generated with `bunx bunderstack blueprint`.
|
|
95
46
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
behavior with `app.runWorker({ allowProcessLocalRealtime: true })`.
|
|
99
|
-
|
|
100
|
-
Inspect the active runtime with `app.realtime.transport` (`'disabled'`,
|
|
101
|
-
`'memory'`, or `'redis'`). The generated blueprint declares only whether
|
|
102
|
-
realtime is required; the host chooses its shared transport and injects its
|
|
103
|
-
runtime configuration.
|
|
104
|
-
|
|
105
|
-
```ts
|
|
106
|
-
const app = await createBunderstack({
|
|
107
|
-
// ...
|
|
108
|
-
realtime: { redis: process.env.REDIS_URL! },
|
|
109
|
-
})
|
|
110
|
-
```
|
|
111
|
-
|
|
112
|
-
```bash
|
|
113
|
-
REDIS_URL=redis://localhost:6379 bun src/server.ts
|
|
114
|
-
REDIS_URL=redis://localhost:6379 bun src/worker.ts
|
|
115
|
-
```
|
|
116
|
-
|
|
117
|
-
Cron tasks (`j.cron()`) are delivered by the host to
|
|
118
|
-
`POST /api/_bunderstack/cron/:name`; storage maintenance uses
|
|
119
|
-
`POST /api/_bunderstack/maintenance/storage-sweep`. Production requires the
|
|
120
|
-
injected `BUNDERSTACK_CRON_SECRET`. Use `await app.startCronScheduler()` only
|
|
121
|
-
for local standalone development. `app.manifest.background` tells Bunderhost
|
|
122
|
-
whether to deploy an always-on worker (queue jobs) or only HTTP-delivered cron
|
|
123
|
-
(cron-only).
|
|
124
|
-
|
|
125
|
-
### Publishing custom writes to realtime
|
|
126
|
-
|
|
127
|
-
Generated CRUD publishes automatically. Writes made directly through `app.db`
|
|
128
|
-
or `ctx.db` are explicit: publish the complete row returned by Drizzle after the
|
|
129
|
-
write commits.
|
|
130
|
-
|
|
131
|
-
```ts
|
|
132
|
-
const [avatar] = await ctx.db
|
|
133
|
-
.update(schema.avatars)
|
|
134
|
-
.set({ status: 'completed' })
|
|
135
|
-
.where(eq(schema.avatars.id, avatarId))
|
|
136
|
-
.returning()
|
|
137
|
-
|
|
138
|
-
await ctx.realtime.publish(schema.avatars, 'update', avatar)
|
|
139
|
-
```
|
|
140
|
-
|
|
141
|
-
The same typed facade is available as `app.realtime`, in tRPC context, and in
|
|
142
|
-
queue-job and cron context. Passing the Drizzle table makes a table-name typo a
|
|
143
|
-
type error and constrains the record to that table's select model.
|
|
144
|
-
|
|
145
|
-
Publish after an enclosing transaction resolves, not from inside it. The full
|
|
146
|
-
row is required because realtime access filtering may inspect its `id`, owner,
|
|
147
|
-
or read-scope columns. Subscriber access checks, Redis fan-out, and replay are
|
|
148
|
-
applied automatically by the existing broker. When server realtime is not
|
|
149
|
-
configured, `realtime.enabled` is `false` and `publish()` is a no-op.
|
|
150
|
-
|
|
151
|
-
## Shipping TypeScript source
|
|
152
|
-
|
|
153
|
-
This package publishes raw TypeScript (`exports` point at `.ts` files). Bun
|
|
154
|
-
consumes it natively. If a Node-based bundler or SSR server processes it,
|
|
155
|
-
make sure the package is bundled rather than externalized — e.g. in Vite:
|
|
156
|
-
|
|
157
|
-
```ts
|
|
158
|
-
ssr: {
|
|
159
|
-
noExternal: [/^bunderstack/]
|
|
160
|
-
}
|
|
161
|
-
```
|
|
47
|
+
See the [workspace documentation](../../README.md) for webhooks, clients,
|
|
48
|
+
storage, collections, lifecycle, and complete examples.
|
|
162
49
|
|
|
163
50
|
## License
|
|
164
51
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bunderstack",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Batteries-included backend framework for Bun:
|
|
3
|
+
"version": "0.17.0-beta.0",
|
|
4
|
+
"description": "Batteries-included backend framework for Bun: type-safe oRPC APIs, auth, storage, realtime, jobs, email, and validated env from one config.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"backend",
|
|
7
7
|
"better-auth",
|
|
@@ -9,9 +9,9 @@
|
|
|
9
9
|
"crud",
|
|
10
10
|
"drizzle",
|
|
11
11
|
"framework",
|
|
12
|
-
"
|
|
12
|
+
"orpc",
|
|
13
13
|
"realtime",
|
|
14
|
-
"
|
|
14
|
+
"typesafe"
|
|
15
15
|
],
|
|
16
16
|
"homepage": "https://github.com/kirill-dev-pro/bunderstack#readme",
|
|
17
17
|
"license": "MIT",
|
|
@@ -47,43 +47,51 @@
|
|
|
47
47
|
"./typeid/pg": "./src/typeid-pg.ts",
|
|
48
48
|
"./env": "./src/env.ts",
|
|
49
49
|
"./blueprint": "./src/blueprint.ts",
|
|
50
|
-
"./trpc": "./src/trpc.ts",
|
|
51
50
|
"./cron": "./src/cron.ts",
|
|
52
|
-
"./email/smtp": "./src/email/smtp.ts"
|
|
51
|
+
"./email/smtp": "./src/email/smtp.ts",
|
|
52
|
+
"./api": "./src/api/types.ts"
|
|
53
53
|
},
|
|
54
54
|
"scripts": {
|
|
55
55
|
"test": "bun test",
|
|
56
|
+
"typecheck": "tsc --noEmit",
|
|
56
57
|
"dev": "bun --hot ../../examples/standalone/server.ts",
|
|
57
58
|
"db:push": "drizzle-kit push",
|
|
58
59
|
"db:migrate": "drizzle-kit migrate"
|
|
59
60
|
},
|
|
60
61
|
"dependencies": {
|
|
61
|
-
"
|
|
62
|
+
"@standard-schema/spec": "1.1.0",
|
|
63
|
+
"valibot": "1.4.2",
|
|
62
64
|
"yaml": "^2.9.0"
|
|
63
65
|
},
|
|
64
66
|
"devDependencies": {
|
|
65
67
|
"@electric-sql/pglite": ">=0.3.0",
|
|
66
68
|
"@libsql/client": ">=0.14.0",
|
|
67
|
-
"@
|
|
69
|
+
"@orpc/openapi": "2.0.0-beta.26",
|
|
70
|
+
"@orpc/bun": "2.0.0-beta.26",
|
|
71
|
+
"@orpc/publisher": "2.0.0-beta.26",
|
|
72
|
+
"@orpc/server": "2.0.0-beta.26",
|
|
73
|
+
"@orpc/valibot": "2.0.0-beta.26",
|
|
68
74
|
"@types/nodemailer": "^6",
|
|
69
75
|
"better-auth": "^1.0.0",
|
|
70
76
|
"drizzle-kit": "^0.30.0",
|
|
71
77
|
"drizzle-orm": "^0.45.0",
|
|
72
|
-
"
|
|
73
|
-
"zod": "^4.4.3"
|
|
78
|
+
"drizzle-valibot": "0.4.2"
|
|
74
79
|
},
|
|
75
80
|
"peerDependencies": {
|
|
76
81
|
"@electric-sql/pglite": ">=0.3.0",
|
|
77
82
|
"@libsql/client": ">=0.14.0",
|
|
78
|
-
"@
|
|
83
|
+
"@orpc/openapi": "2.0.0-beta.26",
|
|
84
|
+
"@orpc/bun": "2.0.0-beta.26",
|
|
85
|
+
"@orpc/publisher": "2.0.0-beta.26",
|
|
86
|
+
"@orpc/server": "2.0.0-beta.26",
|
|
87
|
+
"@orpc/valibot": "2.0.0-beta.26",
|
|
79
88
|
"better-auth": "^1.0.0",
|
|
80
89
|
"drizzle-kit": "^0.30.0",
|
|
81
90
|
"drizzle-orm": "^0.45.0",
|
|
82
|
-
"
|
|
91
|
+
"drizzle-valibot": "0.4.2",
|
|
83
92
|
"nodemailer": ">=6 <10",
|
|
84
93
|
"postgres": ">=3.4.0",
|
|
85
|
-
"typescript": ">=5"
|
|
86
|
-
"zod": "^4.4.3"
|
|
94
|
+
"typescript": ">=5"
|
|
87
95
|
},
|
|
88
96
|
"peerDependenciesMeta": {
|
|
89
97
|
"@electric-sql/pglite": {
|
package/src/access.ts
CHANGED
|
@@ -65,6 +65,8 @@ export type TableAccessInput = {
|
|
|
65
65
|
create?: OperationRule
|
|
66
66
|
update?: OperationRule
|
|
67
67
|
delete?: OperationRule
|
|
68
|
+
/** Explicit write allowlist. Entries override matching system-readonly
|
|
69
|
+
* defaults such as `updatedAt`; `id` remains immutable on update. */
|
|
68
70
|
writableColumns?: string[]
|
|
69
71
|
readonlyColumns?: string[]
|
|
70
72
|
/** Columns matched by `?q=` on list — opt-in; omitted columns are never searched. */
|
|
@@ -103,6 +105,24 @@ export type ResolvedTableAccess = {
|
|
|
103
105
|
|
|
104
106
|
export type ResolvedAccess = Map<string, ResolvedTableAccess>
|
|
105
107
|
|
|
108
|
+
/**
|
|
109
|
+
* Look up a table's resolved access by its physical table name.
|
|
110
|
+
*
|
|
111
|
+
* `ResolvedAccess` is keyed by schema export name, not table name, so every
|
|
112
|
+
* consumer that starts from a physical name needs this scan. It lives here so
|
|
113
|
+
* CRUD, realtime, and route validation cannot drift apart on which tables they
|
|
114
|
+
* consider enabled.
|
|
115
|
+
*/
|
|
116
|
+
export function tableEntryForName(
|
|
117
|
+
access: ResolvedAccess,
|
|
118
|
+
tableName: string,
|
|
119
|
+
): ResolvedTableAccess | undefined {
|
|
120
|
+
for (const entry of access.values()) {
|
|
121
|
+
if (entry.tableName === tableName) return entry
|
|
122
|
+
}
|
|
123
|
+
return undefined
|
|
124
|
+
}
|
|
125
|
+
|
|
106
126
|
const DEFAULT_READONLY = [
|
|
107
127
|
'id',
|
|
108
128
|
'createdAt',
|
|
@@ -190,6 +210,7 @@ function resolveDefaults(
|
|
|
190
210
|
columns: string[],
|
|
191
211
|
): Omit<ResolvedTableAccess, 'tableKey' | 'tableName' | 'enabled'> {
|
|
192
212
|
const listAccess = resolveListAccess(input, columns)
|
|
213
|
+
const explicitlyWritable = new Set(input.writableColumns ?? [])
|
|
193
214
|
return {
|
|
194
215
|
ownerColumn,
|
|
195
216
|
list: input.list ?? 'public',
|
|
@@ -199,7 +220,9 @@ function resolveDefaults(
|
|
|
199
220
|
delete: input.delete ?? (ownerColumn ? 'owner' : 'deny'),
|
|
200
221
|
writableColumns: input.writableColumns,
|
|
201
222
|
readonlyColumns: [
|
|
202
|
-
...DEFAULT_READONLY
|
|
223
|
+
...DEFAULT_READONLY.filter(
|
|
224
|
+
(column) => column === 'id' || !explicitlyWritable.has(column),
|
|
225
|
+
),
|
|
203
226
|
...(input.readonlyColumns ?? []),
|
|
204
227
|
...(ownerColumn ? [ownerColumn] : []),
|
|
205
228
|
],
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import type { InferRouterInputs, InferRouterOutputs } from '@orpc/server'
|
|
2
|
+
|
|
3
|
+
import { pgTable, text } from 'drizzle-orm/pg-core'
|
|
4
|
+
import * as v from 'valibot'
|
|
5
|
+
|
|
6
|
+
import type { ExposedApiTables } from './types'
|
|
7
|
+
|
|
8
|
+
import { pglite } from '../database/pglite'
|
|
9
|
+
import { createBunderstack } from '../index'
|
|
10
|
+
|
|
11
|
+
type Equal<A, B> =
|
|
12
|
+
(<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2
|
|
13
|
+
? true
|
|
14
|
+
: false
|
|
15
|
+
type Expect<T extends true> = T
|
|
16
|
+
|
|
17
|
+
const posts = pgTable('posts', {
|
|
18
|
+
id: text('id').primaryKey(),
|
|
19
|
+
title: text('title').notNull(),
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
const privateNotes = pgTable('private_notes', {
|
|
23
|
+
id: text('id').primaryKey(),
|
|
24
|
+
content: text('content').notNull(),
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
const ownedPosts = pgTable('owned_posts', {
|
|
28
|
+
id: text('id').primaryKey(),
|
|
29
|
+
userId: text('user_id').notNull(),
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
type ImplicitTables = ExposedApiTables<
|
|
33
|
+
{ posts: typeof posts; ownedPosts: typeof ownedPosts },
|
|
34
|
+
undefined
|
|
35
|
+
>
|
|
36
|
+
type _ImplicitAccessHidesUnownedTable = Expect<
|
|
37
|
+
Equal<'posts' extends ImplicitTables ? true : false, false>
|
|
38
|
+
>
|
|
39
|
+
type _ImplicitAccessIncludesConventionTable = Expect<
|
|
40
|
+
Equal<'ownedPosts' extends ImplicitTables ? true : false, true>
|
|
41
|
+
>
|
|
42
|
+
|
|
43
|
+
const typedApp = await createBunderstack({
|
|
44
|
+
schema: { posts, privateNotes },
|
|
45
|
+
database: { adapter: pglite() },
|
|
46
|
+
processEnv: {
|
|
47
|
+
DATABASE_URL: 'memory://',
|
|
48
|
+
BUNDERSTACK_ROLE: 'web',
|
|
49
|
+
},
|
|
50
|
+
access: {
|
|
51
|
+
posts: { crud: true, list: 'public', create: 'public' },
|
|
52
|
+
privateNotes: { crud: false },
|
|
53
|
+
},
|
|
54
|
+
api: (o) => ({
|
|
55
|
+
stats: o.protected
|
|
56
|
+
.input(v.object({ period: v.picklist(['day', 'week']) }))
|
|
57
|
+
.output(
|
|
58
|
+
v.object({ period: v.picklist(['day', 'week']), userId: v.string() }),
|
|
59
|
+
)
|
|
60
|
+
.handler(async ({ input, context }) => {
|
|
61
|
+
const _userId: string = context.user.id
|
|
62
|
+
const _db = context.db
|
|
63
|
+
const _env = context.env
|
|
64
|
+
return {
|
|
65
|
+
period: input.period,
|
|
66
|
+
userId: context.user.id,
|
|
67
|
+
}
|
|
68
|
+
}),
|
|
69
|
+
}),
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
type Api = NonNullable<typeof typedApp.$inferClient>['api']
|
|
73
|
+
|
|
74
|
+
type _HasPosts = Expect<Equal<'posts' extends keyof Api ? true : false, true>>
|
|
75
|
+
type _HidesPrivateNotes = Expect<
|
|
76
|
+
Equal<'privateNotes' extends keyof Api ? true : false, false>
|
|
77
|
+
>
|
|
78
|
+
type _HasStats = Expect<Equal<'stats' extends keyof Api ? true : false, true>>
|
|
79
|
+
|
|
80
|
+
type PostsInputs = InferRouterInputs<Api>['posts']
|
|
81
|
+
type PostsOutputs = InferRouterOutputs<Api>['posts']
|
|
82
|
+
type IsAny<T> = 0 extends 1 & T ? true : false
|
|
83
|
+
type ExpectedUpdateInput = {
|
|
84
|
+
params: { id: string }
|
|
85
|
+
query?: Record<string, unknown>
|
|
86
|
+
headers?: Record<string, unknown>
|
|
87
|
+
body: { title?: string }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
type _CreateInput = Expect<
|
|
91
|
+
Equal<
|
|
92
|
+
PostsInputs['create'],
|
|
93
|
+
Partial<typeof posts.$inferInsert>
|
|
94
|
+
>
|
|
95
|
+
>
|
|
96
|
+
type _GetInput = Expect<Equal<PostsInputs['get'], { id: string }>>
|
|
97
|
+
type _UpdateInputToExpected = Expect<
|
|
98
|
+
PostsInputs['update'] extends ExpectedUpdateInput ? true : false
|
|
99
|
+
>
|
|
100
|
+
type _ExpectedToUpdateInput = Expect<
|
|
101
|
+
ExpectedUpdateInput extends PostsInputs['update'] ? true : false
|
|
102
|
+
>
|
|
103
|
+
type _ListItems = Expect<
|
|
104
|
+
Equal<PostsOutputs['list']['items'], Array<{ id: string; title: string }>>
|
|
105
|
+
>
|
|
106
|
+
type _GetOutputIsTyped = Expect<Equal<IsAny<PostsOutputs['get']>, false>>
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { os, type AnyRouter } from '@orpc/server'
|
|
2
|
+
|
|
3
|
+
import type { ApiContext } from './context'
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
BUNDERSTACK_ERRORS,
|
|
7
|
+
BunderstackError,
|
|
8
|
+
mapBunderstackErrors,
|
|
9
|
+
} from '../errors'
|
|
10
|
+
export type { ProtectedContextAdditions } from './types'
|
|
11
|
+
|
|
12
|
+
export function createApiBuilder<
|
|
13
|
+
TSchema extends Record<string, unknown> = Record<string, unknown>,
|
|
14
|
+
TEnv = Record<string, unknown>,
|
|
15
|
+
>() {
|
|
16
|
+
const base = os
|
|
17
|
+
.$context<ApiContext<TSchema, TEnv>>()
|
|
18
|
+
.errors(BUNDERSTACK_ERRORS)
|
|
19
|
+
.use(mapBunderstackErrors)
|
|
20
|
+
|
|
21
|
+
const protectedProc = base.use(async ({ context, next }) => {
|
|
22
|
+
const session = await context.getSession()
|
|
23
|
+
if (!session.user) {
|
|
24
|
+
throw new BunderstackError('UNAUTHORIZED', 'Authentication required')
|
|
25
|
+
}
|
|
26
|
+
return next({
|
|
27
|
+
context: {
|
|
28
|
+
user: session.user,
|
|
29
|
+
session: {
|
|
30
|
+
activeOrganizationId: session.activeOrganizationId,
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
})
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
public: base,
|
|
38
|
+
protected: protectedProc,
|
|
39
|
+
webhook: base,
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export type BunderstackApiBuilder<
|
|
44
|
+
TSchema extends Record<string, unknown>,
|
|
45
|
+
TEnv = Record<string, unknown>,
|
|
46
|
+
> = ReturnType<typeof createApiBuilder<TSchema, TEnv>>
|
|
47
|
+
|
|
48
|
+
export type ApiFactory<
|
|
49
|
+
TSchema extends Record<string, unknown>,
|
|
50
|
+
TEnv,
|
|
51
|
+
TCustomApiRouter extends AnyRouter,
|
|
52
|
+
> = (builder: BunderstackApiBuilder<TSchema, TEnv>) => TCustomApiRouter
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type { AccessUser, AuthSessionResolver } from '../access'
|
|
2
|
+
import type { DbFor } from '../db'
|
|
3
|
+
import type { EmailFacade } from '../email'
|
|
4
|
+
import type { AuthInstance, StorageFacade } from '../index'
|
|
5
|
+
import type { JobsRuntimeFacade } from '../jobs/define'
|
|
6
|
+
import type { RealtimeFacade } from '../realtime/facade'
|
|
7
|
+
|
|
8
|
+
import { resolveSession } from '../access'
|
|
9
|
+
|
|
10
|
+
export interface ApiContextDeps<
|
|
11
|
+
TSchema extends Record<string, unknown> = Record<string, unknown>,
|
|
12
|
+
TEnv = Record<string, unknown>,
|
|
13
|
+
> {
|
|
14
|
+
db: DbFor<TSchema>
|
|
15
|
+
env: TEnv
|
|
16
|
+
storage: StorageFacade
|
|
17
|
+
email: EmailFacade
|
|
18
|
+
jobs: JobsRuntimeFacade
|
|
19
|
+
realtime: RealtimeFacade<TSchema>
|
|
20
|
+
auth: AuthInstance
|
|
21
|
+
authResolver?: AuthSessionResolver
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ApiContext<
|
|
25
|
+
TSchema extends Record<string, unknown> = Record<string, unknown>,
|
|
26
|
+
TEnv = Record<string, unknown>,
|
|
27
|
+
> {
|
|
28
|
+
db: DbFor<TSchema>
|
|
29
|
+
env: TEnv
|
|
30
|
+
storage: StorageFacade
|
|
31
|
+
email: EmailFacade
|
|
32
|
+
jobs: JobsRuntimeFacade
|
|
33
|
+
realtime: RealtimeFacade<TSchema>
|
|
34
|
+
auth: AuthInstance
|
|
35
|
+
request: Request
|
|
36
|
+
resHeaders: Headers
|
|
37
|
+
getRawBody: () => Promise<string>
|
|
38
|
+
getSession: () => Promise<{
|
|
39
|
+
user: AccessUser | null
|
|
40
|
+
activeOrganizationId: string | null
|
|
41
|
+
}>
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function createApiContext<
|
|
45
|
+
TSchema extends Record<string, unknown> = Record<string, unknown>,
|
|
46
|
+
TEnv = Record<string, unknown>,
|
|
47
|
+
>(
|
|
48
|
+
deps: ApiContextDeps<TSchema, TEnv>,
|
|
49
|
+
request: Request,
|
|
50
|
+
): ApiContext<TSchema, TEnv> {
|
|
51
|
+
// Reserve the body stream before a transport codec consumes `request`.
|
|
52
|
+
const rawBodyRequest = request.clone()
|
|
53
|
+
let rawBodyPromise: Promise<string> | undefined
|
|
54
|
+
let sessionPromise:
|
|
55
|
+
| Promise<{ user: AccessUser | null; activeOrganizationId: string | null }>
|
|
56
|
+
| undefined
|
|
57
|
+
|
|
58
|
+
const getSession = () => {
|
|
59
|
+
if (!sessionPromise) {
|
|
60
|
+
sessionPromise = resolveSession(deps.authResolver, request.headers)
|
|
61
|
+
}
|
|
62
|
+
return sessionPromise
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const getRawBody = () => {
|
|
66
|
+
if (!rawBodyPromise) rawBodyPromise = rawBodyRequest.text()
|
|
67
|
+
return rawBodyPromise
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
db: deps.db,
|
|
72
|
+
env: deps.env,
|
|
73
|
+
storage: deps.storage,
|
|
74
|
+
email: deps.email,
|
|
75
|
+
jobs: deps.jobs,
|
|
76
|
+
realtime: deps.realtime,
|
|
77
|
+
auth: deps.auth,
|
|
78
|
+
request,
|
|
79
|
+
resHeaders: new Headers(),
|
|
80
|
+
getRawBody,
|
|
81
|
+
getSession,
|
|
82
|
+
}
|
|
83
|
+
}
|