@spfn/core 0.2.0-beta.49 → 0.2.0-beta.50
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/LICENSE +1 -1
- package/README.md +181 -366
- package/dist/cache/index.js.map +1 -1
- package/dist/codegen/index.js.map +1 -1
- package/dist/config/index.js.map +1 -1
- package/dist/db/index.d.ts +7 -1
- package/dist/db/index.js +1 -1
- package/dist/db/index.js.map +1 -1
- package/dist/env/index.d.ts +1 -1
- package/dist/env/index.js.map +1 -1
- package/dist/env/loader.d.ts +2 -3
- package/dist/env/loader.js +0 -1
- package/dist/env/loader.js.map +1 -1
- package/dist/errors/index.js.map +1 -1
- package/dist/event/index.js.map +1 -1
- package/dist/event/sse/client.js.map +1 -1
- package/dist/event/sse/index.js.map +1 -1
- package/dist/event/ws/client.js.map +1 -1
- package/dist/event/ws/index.js.map +1 -1
- package/dist/job/index.js.map +1 -1
- package/dist/logger/index.js.map +1 -1
- package/dist/middleware/index.js.map +1 -1
- package/dist/nextjs/index.js.map +1 -1
- package/dist/nextjs/server.js.map +1 -1
- package/dist/route/index.js.map +1 -1
- package/dist/server/index.js +0 -1
- package/dist/server/index.js.map +1 -1
- package/package.json +3 -3
- package/docs/cache.md +0 -133
- package/docs/codegen.md +0 -74
- package/docs/database.md +0 -436
- package/docs/entity.md +0 -539
- package/docs/env.md +0 -499
- package/docs/errors.md +0 -319
- package/docs/event.md +0 -443
- package/docs/job.md +0 -131
- package/docs/logger.md +0 -108
- package/docs/middleware.md +0 -337
- package/docs/nextjs.md +0 -247
- package/docs/repository.md +0 -496
- package/docs/route.md +0 -497
- package/docs/server.md +0 -429
package/LICENSE
CHANGED
package/README.md
CHANGED
|
@@ -1,393 +1,208 @@
|
|
|
1
|
-
# @spfn/core
|
|
1
|
+
# @spfn/core — Type-safe Next.js + Hono backend framework (route DSL → RPC proxy → typed client)
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
`@spfn/core` is the backend runtime for SPFN: a tRPC-style **route DSL** (TypeBox-validated,
|
|
4
|
+
end-to-end typed), a Drizzle/postgres.js **data layer**, an HTTP **server** entry point, and a
|
|
5
|
+
Next.js **RPC proxy + typed client** that wires a browser/RSC app to that backend with
|
|
6
|
+
compile-time-only type inference.
|
|
4
7
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
+
There is **no root barrel** — `@spfn/core` itself imports nothing. Everything is reached
|
|
9
|
+
through subpath exports (`@spfn/core/route`, `@spfn/core/db`, `@spfn/core/nextjs`, …). Each
|
|
10
|
+
module is canonically documented in its own `src/<mod>/README.md`; this file is the index +
|
|
11
|
+
end-to-end flow. Follow the links for API detail.
|
|
8
12
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
## Installation
|
|
13
|
+
## Install
|
|
12
14
|
|
|
13
15
|
```bash
|
|
14
16
|
pnpm add @spfn/core
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
17
|
+
# peer (optional): next ^15 || ^16
|
|
18
|
+
# optional deps: ioredis (cache), ws (websocket events); pg-boss ships as a direct dep for jobs
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Node `>=18.18.0`. ESM-only.
|
|
22
|
+
|
|
23
|
+
> **Apps that define entities must declare the Postgres driver directly:** add
|
|
24
|
+
> `postgres` (and `pg`) to your app's `dependencies`. `@spfn/core` uses postgres.js
|
|
25
|
+
> internally, and Drizzle branches its type resolution on the `postgres`/`pg` peer. If
|
|
26
|
+
> the app doesn't pin the same driver, pnpm resolves `drizzle-orm` to a second instance,
|
|
27
|
+
> `BaseRepository` generics collapse to `unknown`, and RPC responses lose their types.
|
|
28
|
+
> `spfn create` adds these automatically; declare them by hand only when wiring SPFN into
|
|
29
|
+
> an existing app.
|
|
30
|
+
>
|
|
31
|
+
> ```bash
|
|
32
|
+
> pnpm add postgres pg
|
|
33
|
+
> ```
|
|
34
|
+
|
|
35
|
+
## Modules
|
|
36
|
+
|
|
37
|
+
Each entry below is exactly one subpath from `package.json` `exports`. Import path → one-line
|
|
38
|
+
purpose → canonical README.
|
|
39
|
+
|
|
40
|
+
| Import path | Purpose | Doc |
|
|
41
|
+
|-------------|---------|-----|
|
|
42
|
+
| `@spfn/core/route` | tRPC-style route DSL (`route.get(...).input(...).handler(...)`) + `defineRouter` / `registerRoutes` / `defineMiddleware`. The core. | [src/route/README.md](./src/route/README.md) |
|
|
43
|
+
| `@spfn/core/route/types` | Shared route types (`HttpMethod`, route/router type primitives). | [src/route/README.md](./src/route/README.md) |
|
|
44
|
+
| `@spfn/core/server` | HTTP server entry: `defineServerConfig()` → `startServer()`; middleware auto-wiring, infra init, graceful shutdown. | [src/server/README.md](./src/server/README.md) |
|
|
45
|
+
| `@spfn/core/nextjs` | Client-safe: `createApi<AppRouter>()`, `ApiError`, client types. No `next/headers`. | [src/nextjs/README.md](./src/nextjs/README.md) |
|
|
46
|
+
| `@spfn/core/nextjs/server` | Server-only: `createRpcProxy({ routeMap })`, `registerInterceptors`. Uses `next/headers`. | [src/nextjs/README.md](./src/nextjs/README.md) |
|
|
47
|
+
| `@spfn/core/db` | Type-safe PostgreSQL (Drizzle): CRUD helpers, `BaseRepository`, schema helpers, transactions, PG error mapping. Single entry point. | [src/db/README.md](./src/db/README.md) |
|
|
48
|
+
| `@spfn/core/db` → manager | Connection lifecycle, pool, primary/replica, health-check, reconnect (`initDatabase`, `getDatabase`). Re-exported from `@spfn/core/db`. | [src/db/manager/README.md](./src/db/manager/README.md) |
|
|
49
|
+
| `@spfn/core/db` → schema | Drizzle column helpers (`id`, `uuid`, `timestamps`, `foreignKey`, `enumText`, `typedJsonb`, `softDelete`, …). Re-exported from `@spfn/core/db`. | [src/db/schema/README.md](./src/db/schema/README.md) |
|
|
50
|
+
| `@spfn/core/db` → transaction | `Transactional` middleware + `runInTransaction`; AsyncLocalStorage propagation to every repo. Re-exported from `@spfn/core/db`. | [src/db/transaction/README.md](./src/db/transaction/README.md) |
|
|
51
|
+
| `@spfn/core/middleware` | Built-in Hono middleware: `ErrorHandler`, `RequestLogger` (+ masking helper). | [src/middleware/README.md](./src/middleware/README.md) |
|
|
52
|
+
| `@spfn/core/errors` | Serializable HTTP/DB error classes + `ErrorRegistry` for cross-boundary deserialization. | [src/errors/README.md](./src/errors/README.md) |
|
|
53
|
+
| `@spfn/core/env` | Schema-based env validation/parsing (isomorphic). `@spfn/core/env/loader` is the **server-only** file loader (`node:fs`). | [src/env/README.md](./src/env/README.md) |
|
|
54
|
+
| `@spfn/core/config` | `@spfn/core`'s own validated env config (`env`, `envSchema`, `registry`) built on `@spfn/core/env`. | [src/config/README.md](./src/config/README.md) |
|
|
55
|
+
| `@spfn/core/logger` | Zero-dependency structured singleton `logger` + child loggers, level masking. | [src/logger/README.md](./src/logger/README.md) |
|
|
56
|
+
| `@spfn/core/cache` | Singleton Valkey/Redis (ioredis) manager, graceful-degrading (`getCache`, `getCacheRead`). | [src/cache/README.md](./src/cache/README.md) |
|
|
57
|
+
| `@spfn/core/job` | pg-boss background jobs: fluent `job()` builder, cron/run-once/event-driven, `defineJobRouter`. | [src/job/README.md](./src/job/README.md) |
|
|
58
|
+
| `@spfn/core/event` | Decoupled pub/sub (`defineEvent`, `defineEventRouter`, `eventRouteMap`); SSE/WS variants below. | [src/event/README.md](./src/event/README.md) |
|
|
59
|
+
| `@spfn/core/event/sse` | Server SSE handler + token manager (SERVER ONLY). | [src/event/README.md](./src/event/README.md) |
|
|
60
|
+
| `@spfn/core/event/sse/client` | Browser SSE (`EventSource`) client. | [src/event/README.md](./src/event/README.md) |
|
|
61
|
+
| `@spfn/core/event/ws` | Server WebSocket handler (SERVER ONLY; `ws` optional dep). | [src/event/README.md](./src/event/README.md) |
|
|
62
|
+
| `@spfn/core/event/ws/client` | Browser WebSocket client. | [src/event/README.md](./src/event/README.md) |
|
|
63
|
+
| `@spfn/core/codegen` | Pluggable codegen orchestrator + the built-in `@spfn/core:route-map` generator that produces the proxy's `routeMap`. | [src/codegen/README.md](./src/codegen/README.md) |
|
|
64
|
+
|
|
65
|
+
`db/manager`, `db/schema`, `db/transaction` have **no package subpath** of their own — they are
|
|
66
|
+
internal modules re-exported by `@spfn/core/db`. Import them from `@spfn/core/db`. (See Pitfalls
|
|
67
|
+
for the `./client` subpath.)
|
|
68
|
+
|
|
69
|
+
## How it works
|
|
70
|
+
|
|
71
|
+
End-to-end, a SPFN backend is **defined once** on the server and consumed type-safely from the
|
|
72
|
+
Next.js app. Types flow purely through TypeScript inference; the only generated artifact is the
|
|
73
|
+
proxy's `routeMap`.
|
|
74
|
+
|
|
75
|
+
```
|
|
76
|
+
① route DSL ② defineRouter ③ defineServerConfig → startServer
|
|
77
|
+
route.get('/users/:id') defineRouter({ getUser, defineServerConfig()
|
|
78
|
+
.input({ params }) createUser }) .routes(appRouter).build()
|
|
79
|
+
.handler(c => …) export type AppRouter startServer() → Hono on :8790
|
|
80
|
+
│ = typeof appRouter ▲
|
|
81
|
+
│ TypeBox = runtime validation + compile-time types │ registerRoutes mounts routes
|
|
82
|
+
▼ │
|
|
83
|
+
④ codegen (@spfn/core:route-map) ──► routeMap = { getUser: { method:'GET', path:'/users/:id' }, … }
|
|
84
|
+
│
|
|
85
|
+
▼
|
|
86
|
+
⑤ Next.js RPC proxy ⑥ typed client
|
|
87
|
+
app/api/rpc/[routeName]/route.ts lib/api.ts
|
|
88
|
+
createRpcProxy({ routeMap }) createApi<AppRouter>() (no codegen for the client)
|
|
89
|
+
GET/POST /api/rpc/{routeName} api.getUser.call({ params:{ id } })
|
|
90
|
+
resolves real method+path from routeMap └─ fully typed input + output
|
|
91
|
+
forwards to backend, runs interceptors
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
1. **Define a route** with `route.<method>(path).input({...}).handler(c => …)` from
|
|
95
|
+
`@spfn/core/route`. TypeBox schemas in `.input()` give both runtime validation and the
|
|
96
|
+
compile-time types the handler (`await c.data()`) and the client see. The handler's **return
|
|
97
|
+
type is inferred** — no manual response typing.
|
|
98
|
+
2. **Compose routes** with `defineRouter({ … })` and export `type AppRouter = typeof appRouter`.
|
|
99
|
+
That type is the single source of truth for the client; no client codegen reads it.
|
|
100
|
+
3. **Boot the server** with `defineServerConfig().routes(appRouter).build()` then `startServer()`
|
|
101
|
+
(`@spfn/core/server`). It auto-wires `ErrorHandler`/`RequestLogger`, inits DB/cache, mounts
|
|
102
|
+
routes via `registerRoutes`, and runs jobs/events. A request is matched by Hono → global
|
|
103
|
+
middleware → route middleware (`.use([...])`, e.g. `Transactional()`) → input validation/type
|
|
104
|
+
conversion → handler → serialized response.
|
|
105
|
+
4. **Generate the route-map** with codegen (`@spfn/core:route-map`, e.g. `pnpm codegen`). It emits
|
|
106
|
+
`routeName → { method, path }` — the only thing the proxy needs to resolve the *real* backend
|
|
107
|
+
method/path.
|
|
108
|
+
5. **Mount the RPC proxy** in Next.js: `createRpcProxy({ routeMap })` from
|
|
109
|
+
`@spfn/core/nextjs/server` as the `app/api/rpc/[routeName]/route.ts` catch-all. The client only
|
|
110
|
+
ever sends `GET` (no body) or `POST` (body/formData) to `/api/rpc/{routeName}`; the proxy looks
|
|
111
|
+
up `routeMap[routeName]`, substitutes `:params`, and forwards to the backend with the resolved
|
|
112
|
+
method. Package route maps (`authRouteMap`, `eventRouteMap`) merge into the same `routeMap`.
|
|
113
|
+
6. **Call it** through `createApi<AppRouter>()` from `@spfn/core/nextjs`. The client is a `Proxy`
|
|
114
|
+
over `AppRouter` — `api.getUser.call({ params })` is fully typed in and out, with zero runtime
|
|
115
|
+
type cost. Errors come back as `ApiError`, or as the original typed error when its class is in
|
|
116
|
+
the client's `errorRegistry` (the core registry is always merged in).
|
|
117
|
+
|
|
118
|
+
## Quick example
|
|
80
119
|
|
|
81
120
|
```typescript
|
|
82
|
-
//
|
|
83
|
-
import { route } from '@spfn/core/route';
|
|
121
|
+
// server/router.ts — ① + ②
|
|
122
|
+
import { defineRouter, route } from '@spfn/core/route';
|
|
84
123
|
import { Transactional } from '@spfn/core/db';
|
|
85
124
|
import { Type } from '@sinclair/typebox';
|
|
86
|
-
import { UserRepository } from '../repositories/user.repository';
|
|
87
|
-
|
|
88
|
-
const userRepo = new UserRepository();
|
|
89
|
-
|
|
90
|
-
export const getUsers = route.get('/users')
|
|
91
|
-
.handler(async () => {
|
|
92
|
-
return userRepo.findAll();
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
export const getUser = route.get('/users/:id')
|
|
96
|
-
.input({
|
|
97
|
-
params: Type.Object({ id: Type.String() })
|
|
98
|
-
})
|
|
99
|
-
.handler(async (c) => {
|
|
100
|
-
const { params } = await c.data();
|
|
101
|
-
const user = await userRepo.findById(params.id);
|
|
102
125
|
|
|
103
|
-
|
|
126
|
+
export const appRouter = defineRouter({
|
|
127
|
+
getUser: route.get('/users/:id')
|
|
128
|
+
.input({ params: Type.Object({ id: Type.String() }) })
|
|
129
|
+
.handler(async (c) =>
|
|
104
130
|
{
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
body: Type.Object({
|
|
114
|
-
email: Type.String({ format: 'email' }),
|
|
115
|
-
name: Type.String({ minLength: 1 })
|
|
116
|
-
})
|
|
117
|
-
})
|
|
118
|
-
.use([Transactional()])
|
|
119
|
-
.handler(async (c) => {
|
|
120
|
-
const { body } = await c.data();
|
|
121
|
-
return userRepo.create(body);
|
|
122
|
-
});
|
|
123
|
-
|
|
124
|
-
export const updateUser = route.patch('/users/:id')
|
|
125
|
-
.input({
|
|
126
|
-
params: Type.Object({ id: Type.String() }),
|
|
127
|
-
body: Type.Object({
|
|
128
|
-
email: Type.Optional(Type.String({ format: 'email' })),
|
|
129
|
-
name: Type.Optional(Type.String({ minLength: 1 }))
|
|
130
|
-
})
|
|
131
|
-
})
|
|
132
|
-
.use([Transactional()])
|
|
133
|
-
.handler(async (c) => {
|
|
134
|
-
const { params, body } = await c.data();
|
|
135
|
-
const user = await userRepo.update(params.id, body);
|
|
136
|
-
|
|
137
|
-
if (!user)
|
|
138
|
-
{
|
|
139
|
-
throw new Error('User not found');
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
return user;
|
|
143
|
-
});
|
|
144
|
-
|
|
145
|
-
export const deleteUser = route.delete('/users/:id')
|
|
146
|
-
.input({
|
|
147
|
-
params: Type.Object({ id: Type.String() })
|
|
148
|
-
})
|
|
149
|
-
.use([Transactional()])
|
|
150
|
-
.handler(async (c) => {
|
|
151
|
-
const { params } = await c.data();
|
|
152
|
-
const user = await userRepo.delete(params.id);
|
|
153
|
-
|
|
154
|
-
if (!user)
|
|
131
|
+
const { params } = await c.data(); // params.id: string
|
|
132
|
+
return { id: params.id, name: 'John' }; // return type inferred
|
|
133
|
+
}),
|
|
134
|
+
|
|
135
|
+
createUser: route.post('/users')
|
|
136
|
+
.input({ body: Type.Object({ name: Type.String() }) })
|
|
137
|
+
.use([Transactional()]) // auto commit/rollback
|
|
138
|
+
.handler(async (c) =>
|
|
155
139
|
{
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
return { success: true };
|
|
160
|
-
});
|
|
161
|
-
```
|
|
162
|
-
|
|
163
|
-
### 4. Configure Server
|
|
164
|
-
|
|
165
|
-
```typescript
|
|
166
|
-
// src/server/server.config.ts
|
|
167
|
-
import { defineServerConfig, defineRouter } from '@spfn/core/server';
|
|
168
|
-
import * as userRoutes from './routes/users';
|
|
169
|
-
|
|
170
|
-
const appRouter = defineRouter({
|
|
171
|
-
...userRoutes
|
|
140
|
+
const { body } = await c.data();
|
|
141
|
+
return { id: '2', name: body.name };
|
|
142
|
+
}),
|
|
172
143
|
});
|
|
173
144
|
|
|
174
|
-
export default defineServerConfig()
|
|
175
|
-
.port(8790)
|
|
176
|
-
.routes(appRouter)
|
|
177
|
-
.build();
|
|
178
|
-
|
|
179
145
|
export type AppRouter = typeof appRouter;
|
|
180
146
|
```
|
|
181
147
|
|
|
182
|
-
### 5. Start Server
|
|
183
|
-
|
|
184
148
|
```typescript
|
|
185
|
-
//
|
|
186
|
-
import { startServer } from '@spfn/core/server';
|
|
149
|
+
// server/index.ts — ③
|
|
150
|
+
import { defineServerConfig, startServer } from '@spfn/core/server';
|
|
151
|
+
import { appRouter } from './router';
|
|
187
152
|
|
|
188
|
-
|
|
153
|
+
export default defineServerConfig().port(8790).routes(appRouter).build();
|
|
154
|
+
await startServer(); // Hono on :8790
|
|
189
155
|
```
|
|
190
156
|
|
|
191
|
-
### 6. Environment Variables
|
|
192
|
-
|
|
193
|
-
```bash
|
|
194
|
-
# .env
|
|
195
|
-
DATABASE_URL=postgresql://localhost:5432/mydb
|
|
196
|
-
PORT=8790
|
|
197
|
-
```
|
|
198
|
-
|
|
199
|
-
---
|
|
200
|
-
|
|
201
|
-
## Architecture Overview
|
|
202
|
-
|
|
203
|
-
```
|
|
204
|
-
┌─────────────────────────────────────────┐
|
|
205
|
-
│ Routes Layer │ API endpoints + validation
|
|
206
|
-
│ route.get('/users/:id').handler(...) │
|
|
207
|
-
└──────────────┬──────────────────────────┘
|
|
208
|
-
│
|
|
209
|
-
┌──────────────▼──────────────────────────┐
|
|
210
|
-
│ Repository Layer │ Business logic + data access
|
|
211
|
-
│ class UserRepository extends Base... │
|
|
212
|
-
└──────────────┬──────────────────────────┘
|
|
213
|
-
│
|
|
214
|
-
┌──────────────▼──────────────────────────┐
|
|
215
|
-
│ Entity Layer │ Database schema (Drizzle)
|
|
216
|
-
│ pgTable('users', { id, email, ... }) │
|
|
217
|
-
└─────────────────────────────────────────┘
|
|
218
|
-
```
|
|
219
|
-
|
|
220
|
-
**Key Principles:**
|
|
221
|
-
- **Route**: Thin layer, input validation only
|
|
222
|
-
- **Repository**: All business logic and DB access
|
|
223
|
-
- **Entity**: Schema definition only, no logic
|
|
224
|
-
|
|
225
|
-
---
|
|
226
|
-
|
|
227
|
-
## Directory Structure
|
|
228
|
-
|
|
229
|
-
```
|
|
230
|
-
src/server/
|
|
231
|
-
├── entities/ # Database schema
|
|
232
|
-
│ ├── users.ts
|
|
233
|
-
│ └── index.ts
|
|
234
|
-
├── repositories/ # Data access + business logic
|
|
235
|
-
│ ├── user.repository.ts
|
|
236
|
-
│ └── index.ts
|
|
237
|
-
├── routes/ # API routes
|
|
238
|
-
│ ├── users.ts
|
|
239
|
-
│ └── index.ts
|
|
240
|
-
├── server.config.ts # Server configuration
|
|
241
|
-
└── index.ts # Entry point
|
|
242
|
-
```
|
|
243
|
-
|
|
244
|
-
---
|
|
245
|
-
|
|
246
|
-
## Core Concepts
|
|
247
|
-
|
|
248
|
-
### Route Definition
|
|
249
|
-
|
|
250
|
-
```typescript
|
|
251
|
-
import { route } from '@spfn/core/route';
|
|
252
|
-
import { Type } from '@sinclair/typebox';
|
|
253
|
-
|
|
254
|
-
// GET with params and query
|
|
255
|
-
route.get('/users/:id')
|
|
256
|
-
.input({
|
|
257
|
-
params: Type.Object({ id: Type.String() }),
|
|
258
|
-
query: Type.Object({ include: Type.Optional(Type.String()) })
|
|
259
|
-
})
|
|
260
|
-
.handler(async (c) => {
|
|
261
|
-
const { params, query } = await c.data();
|
|
262
|
-
// params.id, query.include are fully typed
|
|
263
|
-
});
|
|
264
|
-
|
|
265
|
-
// POST with body
|
|
266
|
-
route.post('/users')
|
|
267
|
-
.input({
|
|
268
|
-
body: Type.Object({
|
|
269
|
-
email: Type.String(),
|
|
270
|
-
name: Type.String()
|
|
271
|
-
})
|
|
272
|
-
})
|
|
273
|
-
.handler(async (c) => {
|
|
274
|
-
const { body } = await c.data();
|
|
275
|
-
// body.email, body.name are fully typed
|
|
276
|
-
});
|
|
277
|
-
```
|
|
278
|
-
|
|
279
|
-
### Repository Pattern
|
|
280
|
-
|
|
281
|
-
```typescript
|
|
282
|
-
import { BaseRepository } from '@spfn/core/db';
|
|
283
|
-
|
|
284
|
-
export class UserRepository extends BaseRepository
|
|
285
|
-
{
|
|
286
|
-
// Protected helpers available:
|
|
287
|
-
// _findOne, _findMany, _create, _createMany
|
|
288
|
-
// _updateOne, _updateMany, _deleteOne, _deleteMany
|
|
289
|
-
// _count, _upsert
|
|
290
|
-
|
|
291
|
-
async findActive()
|
|
292
|
-
{
|
|
293
|
-
return this._findMany(users, {
|
|
294
|
-
where: { isActive: true },
|
|
295
|
-
orderBy: desc(users.createdAt),
|
|
296
|
-
limit: 10
|
|
297
|
-
});
|
|
298
|
-
}
|
|
299
|
-
}
|
|
300
|
-
```
|
|
301
|
-
|
|
302
|
-
### Transaction
|
|
303
|
-
|
|
304
157
|
```typescript
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
route.post('/users')
|
|
309
|
-
.use([Transactional()])
|
|
310
|
-
.handler(async (c) => {
|
|
311
|
-
// Auto commit on success
|
|
312
|
-
// Auto rollback on error
|
|
313
|
-
});
|
|
158
|
+
// app/api/rpc/[routeName]/route.ts — ⑤ (server-only)
|
|
159
|
+
import { createRpcProxy } from '@spfn/core/nextjs/server';
|
|
160
|
+
import { routeMap } from '@/generated/route-map'; // ④ codegen output
|
|
314
161
|
|
|
315
|
-
|
|
316
|
-
import { runWithTransaction } from '@spfn/core/db';
|
|
317
|
-
|
|
318
|
-
await runWithTransaction(async () => {
|
|
319
|
-
await userRepo.create(userData);
|
|
320
|
-
await profileRepo.create(profileData);
|
|
321
|
-
});
|
|
162
|
+
export const { GET, POST } = createRpcProxy({ routeMap });
|
|
322
163
|
```
|
|
323
164
|
|
|
324
|
-
### Schema Helpers
|
|
325
|
-
|
|
326
165
|
```typescript
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
---
|
|
371
|
-
|
|
372
|
-
## CLI Commands
|
|
373
|
-
|
|
374
|
-
```bash
|
|
375
|
-
# Migration
|
|
376
|
-
npx spfn db generate # Generate migration
|
|
377
|
-
npx spfn db migrate # Apply migration
|
|
378
|
-
|
|
379
|
-
# Development
|
|
380
|
-
pnpm spfn:dev # Start dev server (auto codegen)
|
|
381
|
-
|
|
382
|
-
# API Client Generation
|
|
383
|
-
pnpm spfn codegen run
|
|
384
|
-
|
|
385
|
-
# Database Studio
|
|
386
|
-
pnpm drizzle-kit studio
|
|
387
|
-
```
|
|
388
|
-
|
|
389
|
-
---
|
|
390
|
-
|
|
391
|
-
## License
|
|
392
|
-
|
|
393
|
-
MIT
|
|
166
|
+
// lib/api.ts — ⑥ (client-safe; no codegen)
|
|
167
|
+
import { createApi } from '@spfn/core/nextjs';
|
|
168
|
+
import type { AppRouter } from '@/server/router';
|
|
169
|
+
|
|
170
|
+
export const api = createApi<AppRouter>();
|
|
171
|
+
|
|
172
|
+
// anywhere (RSC / client / server action):
|
|
173
|
+
const user = await api.getUser.call({ params: { id: '123' } }); // typed { id, name }
|
|
174
|
+
const made = await api.createUser.call({ body: { name: 'A' } });
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
## Pitfalls
|
|
178
|
+
|
|
179
|
+
- **No root barrel.** `import … from '@spfn/core'` does not resolve. Import from a subpath
|
|
180
|
+
(`@spfn/core/route`, `@spfn/core/db`, …). The list above *is* the complete public surface.
|
|
181
|
+
- **Use `@spfn/core/nextjs` for the client, not `@spfn/core/client`.** `package.json` still lists a
|
|
182
|
+
`./client` export, but the build does not emit it (no `src/client`; the tsup `client` entry is
|
|
183
|
+
disabled). `createApi` / `ApiError` and all client types ship from **`@spfn/core/nextjs`**. Treat
|
|
184
|
+
`@spfn/core/client` as non-functional.
|
|
185
|
+
- **Client vs. server boundary is load-bearing.** `@spfn/core/nextjs/server` pulls in
|
|
186
|
+
`next/headers` + `next/server`; never import it from a Client Component. `@spfn/core/env/loader`,
|
|
187
|
+
`@spfn/core/event/sse`, `@spfn/core/event/ws` are server-only (`node:fs` / Hono / `node:crypto`).
|
|
188
|
+
Client code uses the `*/client` and isomorphic entry points only.
|
|
189
|
+
- **`db/manager`, `db/schema`, `db/transaction` are not package subpaths.** Importing
|
|
190
|
+
`@spfn/core/db/transaction` fails to resolve — import those symbols from `@spfn/core/db`.
|
|
191
|
+
- **The client needs no codegen; the proxy does.** `createApi<AppRouter>()` is metadata-free and
|
|
192
|
+
driven by the `AppRouter` *type*. The generated `routeMap` is consumed by `createRpcProxy`. A
|
|
193
|
+
`routeName` missing from the merged `routeMap` is a **proxy 404**, not a backend 404 — re-run
|
|
194
|
+
codegen after adding routes.
|
|
195
|
+
- **The proxy decides the real HTTP method.** The client only sends GET/POST to `/api/rpc/...`; a
|
|
196
|
+
PUT/PATCH/DELETE route still works because the backend method comes from `routeMap[routeName]`.
|
|
197
|
+
- **Cache/event/job degrade or depend on optional deps.** `@spfn/core/cache` runs disabled (getters
|
|
198
|
+
return `undefined`) without `CACHE_*` config or `ioredis`; WebSocket events need the optional `ws`
|
|
199
|
+
dep. Don't assume they throw.
|
|
200
|
+
|
|
201
|
+
## Related packages
|
|
202
|
+
|
|
203
|
+
Other SPFN packages build on `@spfn/core`:
|
|
204
|
+
|
|
205
|
+
- [`@spfn/auth`](../auth/README.md) — auth/session; exports `authRouteMap` and auto-registers proxy
|
|
206
|
+
interceptors (merge its route map into `createRpcProxy`).
|
|
207
|
+
- `@spfn/cms`, `@spfn/workflow`, `@spfn/notification`, `@spfn/monitor`, `@spfn/cli`
|
|
208
|
+
— see each package's README under `packages/`.
|