@spfn/core 0.2.0-beta.1 → 0.2.0-beta.10
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 +262 -1092
- package/dist/{boss-D-fGtVgM.d.ts → boss-DI1r4kTS.d.ts} +68 -11
- package/dist/codegen/index.d.ts +55 -8
- package/dist/codegen/index.js +159 -5
- package/dist/codegen/index.js.map +1 -1
- package/dist/config/index.d.ts +36 -0
- package/dist/config/index.js +15 -6
- package/dist/config/index.js.map +1 -1
- package/dist/db/index.d.ts +13 -0
- package/dist/db/index.js +40 -6
- package/dist/db/index.js.map +1 -1
- package/dist/env/index.d.ts +82 -3
- package/dist/env/index.js +81 -14
- package/dist/env/index.js.map +1 -1
- package/dist/env/loader.d.ts +87 -0
- package/dist/env/loader.js +70 -0
- package/dist/env/loader.js.map +1 -0
- package/dist/event/index.d.ts +3 -70
- package/dist/event/index.js +10 -1
- package/dist/event/index.js.map +1 -1
- package/dist/event/sse/client.d.ts +82 -0
- package/dist/event/sse/client.js +115 -0
- package/dist/event/sse/client.js.map +1 -0
- package/dist/event/sse/index.d.ts +40 -0
- package/dist/event/sse/index.js +92 -0
- package/dist/event/sse/index.js.map +1 -0
- package/dist/job/index.d.ts +54 -8
- package/dist/job/index.js +61 -12
- package/dist/job/index.js.map +1 -1
- package/dist/middleware/index.d.ts +102 -11
- package/dist/middleware/index.js +2 -2
- package/dist/middleware/index.js.map +1 -1
- package/dist/nextjs/index.d.ts +2 -2
- package/dist/nextjs/index.js +36 -4
- package/dist/nextjs/index.js.map +1 -1
- package/dist/nextjs/server.d.ts +62 -15
- package/dist/nextjs/server.js +102 -33
- package/dist/nextjs/server.js.map +1 -1
- package/dist/route/index.d.ts +227 -15
- package/dist/route/index.js +307 -31
- package/dist/route/index.js.map +1 -1
- package/dist/route/types.d.ts +2 -31
- package/dist/router-Di7ENoah.d.ts +151 -0
- package/dist/server/index.d.ts +153 -6
- package/dist/server/index.js +216 -14
- package/dist/server/index.js.map +1 -1
- package/dist/types-B-e_f2dQ.d.ts +121 -0
- package/dist/{types-DRG2XMTR.d.ts → types-BOPTApC2.d.ts} +91 -3
- package/docs/cache.md +133 -0
- package/docs/codegen.md +74 -0
- package/docs/database.md +346 -0
- package/docs/entity.md +539 -0
- package/docs/env.md +477 -0
- package/docs/errors.md +319 -0
- package/docs/event.md +116 -0
- package/docs/file-upload.md +717 -0
- package/docs/job.md +131 -0
- package/docs/logger.md +108 -0
- package/docs/middleware.md +337 -0
- package/docs/nextjs.md +241 -0
- package/docs/repository.md +496 -0
- package/docs/route.md +497 -0
- package/docs/server.md +307 -0
- package/package.json +18 -3
package/README.md
CHANGED
|
@@ -1,1223 +1,393 @@
|
|
|
1
|
-
# @spfn/core
|
|
1
|
+
# @spfn/core
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Type-safe Node.js backend framework built on Hono + Drizzle ORM.
|
|
4
4
|
|
|
5
5
|
[](https://www.npmjs.com/package/@spfn/core)
|
|
6
6
|
[](https://opensource.org/licenses/MIT)
|
|
7
7
|
[](https://www.typescriptlang.org/)
|
|
8
8
|
|
|
9
|
-
> **
|
|
9
|
+
> **Beta Release**: Core APIs are stable but may have minor changes before 1.0.
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
## Table of Contents
|
|
14
|
-
|
|
15
|
-
- [Overview & Philosophy](#overview--philosophy)
|
|
16
|
-
- [System Architecture](#system-architecture)
|
|
17
|
-
- [Module Architecture](#module-architecture)
|
|
18
|
-
- [Type System](#type-system)
|
|
19
|
-
- [Integration Points](#integration-points)
|
|
20
|
-
- [Design Decisions](#design-decisions)
|
|
21
|
-
- [Extension Points](#extension-points)
|
|
22
|
-
- [Migration Guides](#migration-guides)
|
|
23
|
-
- [Module Exports](#module-exports)
|
|
24
|
-
- [Quick Reference](#quick-reference)
|
|
25
|
-
|
|
26
|
-
---
|
|
27
|
-
|
|
28
|
-
## Overview & Philosophy
|
|
29
|
-
|
|
30
|
-
SPFN (Superfunction) is a full-stack TypeScript framework that provides **end-to-end type safety** from database to frontend with a **tRPC-inspired developer experience**.
|
|
11
|
+
## Installation
|
|
31
12
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
1. **Type Safety First**: Types flow from database schema → server routes → client API
|
|
35
|
-
2. **Developer Experience**: tRPC-style API with method chaining (`.params().query().call()`)
|
|
36
|
-
3. **Explicit over Magic**: No file-based routing, explicit imports for tree-shaking
|
|
37
|
-
4. **Security by Default**: HttpOnly cookies, API Route Proxy, environment isolation
|
|
38
|
-
5. **Production Ready**: Transaction management, read/write separation, graceful shutdown
|
|
39
|
-
|
|
40
|
-
### Design Philosophy
|
|
41
|
-
|
|
42
|
-
**Inspired by tRPC, Built for Production:**
|
|
43
|
-
|
|
44
|
-
```typescript
|
|
45
|
-
// tRPC-style API calls with structured input
|
|
46
|
-
const user = await api.getUser.call({
|
|
47
|
-
params: { id: '123' },
|
|
48
|
-
query: { include: 'posts' }
|
|
49
|
-
});
|
|
50
|
-
// ^? { id: string; name: string; email: string; posts: Post[] }
|
|
13
|
+
```bash
|
|
14
|
+
pnpm add @spfn/core
|
|
51
15
|
```
|
|
52
16
|
|
|
53
|
-
|
|
54
|
-
- Cookie handling via API Route Proxy
|
|
55
|
-
- Transaction management with AsyncLocalStorage
|
|
56
|
-
- Read/Write database separation
|
|
57
|
-
- Graceful shutdown and health checks
|
|
58
|
-
- Lifecycle hooks for extensibility
|
|
59
|
-
|
|
60
|
-
---
|
|
61
|
-
|
|
62
|
-
## System Architecture
|
|
17
|
+
## Quick Start
|
|
63
18
|
|
|
64
|
-
###
|
|
65
|
-
|
|
66
|
-
```
|
|
67
|
-
+---------------------------------------------------------------+
|
|
68
|
-
| Next.js Application |
|
|
69
|
-
| +----------------------------------------------------------+ |
|
|
70
|
-
| | Frontend (React) | |
|
|
71
|
-
| | - Server Components: SSR, ISR, Static | |
|
|
72
|
-
| | - Client Components: Interactive UI | |
|
|
73
|
-
| +---------------------------+------------------------------+ |
|
|
74
|
-
| | |
|
|
75
|
-
| | import { api } from '@spfn/...' |
|
|
76
|
-
| | api.getUser.call({ params }) |
|
|
77
|
-
| v |
|
|
78
|
-
| +----------------------------------------------------------+ |
|
|
79
|
-
| | RPC Proxy (Edge/Node.js) | |
|
|
80
|
-
| | app/api/rpc/[routeName]/route.ts | |
|
|
81
|
-
| | | |
|
|
82
|
-
| | 1. Resolve routeName → method/path from router | |
|
|
83
|
-
| | | |
|
|
84
|
-
| | 2. Request Interceptors | |
|
|
85
|
-
| | - Auth token injection | |
|
|
86
|
-
| | - Cookie forwarding | |
|
|
87
|
-
| | - Header manipulation | |
|
|
88
|
-
| | | |
|
|
89
|
-
| | 3. Forward to SPFN Server | |
|
|
90
|
-
| | fetch(SPFN_API_URL + resolvedPath) | |
|
|
91
|
-
| | | |
|
|
92
|
-
| | 4. Response Interceptors | |
|
|
93
|
-
| | - Set HttpOnly cookies | |
|
|
94
|
-
| | - Transform response | |
|
|
95
|
-
| | - Error handling | |
|
|
96
|
-
| +---------------------------+------------------------------+ |
|
|
97
|
-
+-------------------------------+--------------------------------+
|
|
98
|
-
|
|
|
99
|
-
| HTTP Request
|
|
100
|
-
v
|
|
101
|
-
+---------------------------------------------------------------+
|
|
102
|
-
| SPFN API Server (Node.js) |
|
|
103
|
-
| +----------------------------------------------------------+ |
|
|
104
|
-
| | Hono Web Framework | |
|
|
105
|
-
| | | |
|
|
106
|
-
| | 12-Step Middleware Pipeline: | |
|
|
107
|
-
| | 1. Logger | |
|
|
108
|
-
| | 2. CORS | |
|
|
109
|
-
| | 3. Global Middlewares | |
|
|
110
|
-
| | 4. Route-specific Middlewares | |
|
|
111
|
-
| | 5. Request Validation (TypeBox) | |
|
|
112
|
-
| | 6. Route Handler | |
|
|
113
|
-
| | 7-12. Response processing, error handling | |
|
|
114
|
-
| +---------------------------+------------------------------+ |
|
|
115
|
-
| | |
|
|
116
|
-
| | define-route System |
|
|
117
|
-
| v |
|
|
118
|
-
| +----------------------------------------------------------+ |
|
|
119
|
-
| | Route Handlers | |
|
|
120
|
-
| | - Type-safe input validation | |
|
|
121
|
-
| | - Transaction middleware | |
|
|
122
|
-
| | - Business logic | |
|
|
123
|
-
| +---------------------------+------------------------------+ |
|
|
124
|
-
| | |
|
|
125
|
-
| | Database Queries |
|
|
126
|
-
| v |
|
|
127
|
-
| +----------------------------------------------------------+ |
|
|
128
|
-
| | Database Layer (Drizzle ORM) | |
|
|
129
|
-
| | - Helper functions (findOne, create, etc.) | |
|
|
130
|
-
| | - Transaction propagation (AsyncLocalStorage) | |
|
|
131
|
-
| | - Read/Write separation | |
|
|
132
|
-
| +---------------------------+------------------------------+ |
|
|
133
|
-
+-------------------------------+--------------------------------+
|
|
134
|
-
|
|
|
135
|
-
| SQL Queries
|
|
136
|
-
v
|
|
137
|
-
+---------------------------------------------------------------+
|
|
138
|
-
| PostgreSQL Database |
|
|
139
|
-
| - Primary (Read/Write) |
|
|
140
|
-
| - Replica (Read-only) [optional] |
|
|
141
|
-
+---------------------------------------------------------------+
|
|
142
|
-
```
|
|
143
|
-
|
|
144
|
-
### Request Flow Example
|
|
19
|
+
### 1. Define Entity
|
|
145
20
|
|
|
146
21
|
```typescript
|
|
147
|
-
//
|
|
148
|
-
|
|
149
|
-
import {
|
|
150
|
-
import type { AppRouter } from '@/server/router';
|
|
151
|
-
|
|
152
|
-
const api = createApi<AppRouter>();
|
|
153
|
-
const user = await api.getUser.call({ params: { id: params.id } });
|
|
154
|
-
// → GET /api/rpc/getUser?input={"params":{"id":"123"}}
|
|
155
|
-
|
|
156
|
-
// 2. RPC Proxy
|
|
157
|
-
// app/api/rpc/[routeName]/route.ts
|
|
158
|
-
import { appRouter } from '@/server/router';
|
|
159
|
-
import { createRpcProxy } from '@spfn/core/nextjs/server';
|
|
160
|
-
|
|
161
|
-
export const { GET, POST } = createRpcProxy({ router: appRouter });
|
|
162
|
-
// - Resolves routeName → method/path from router
|
|
163
|
-
// - Forwards to http://localhost:8790/users/123
|
|
164
|
-
// - Applies interceptors (auth, cookies)
|
|
165
|
-
|
|
166
|
-
// 3. SPFN Server Route Handler
|
|
167
|
-
// src/server/routes/users.ts
|
|
168
|
-
export const getUser = route.get('/users/:id')
|
|
169
|
-
.input({
|
|
170
|
-
params: Type.Object({ id: Type.String() }),
|
|
171
|
-
})
|
|
172
|
-
.handler(async (c) => {
|
|
173
|
-
const { params } = await c.data();
|
|
174
|
-
const user = await userRepo.findById(params.id);
|
|
175
|
-
return user;
|
|
176
|
-
});
|
|
177
|
-
|
|
178
|
-
// 4. Database Query
|
|
179
|
-
// Drizzle ORM with helper function
|
|
180
|
-
// SELECT * FROM users WHERE id = $1
|
|
181
|
-
|
|
182
|
-
// 5. Response flows back through interceptors → proxy → client
|
|
183
|
-
```
|
|
184
|
-
|
|
185
|
-
---
|
|
186
|
-
|
|
187
|
-
## Module Architecture
|
|
188
|
-
|
|
189
|
-
### 1. Route System (`src/route/`)
|
|
190
|
-
|
|
191
|
-
**Purpose**: Type-safe routing with automatic validation
|
|
192
|
-
|
|
193
|
-
**Architecture**:
|
|
194
|
-
|
|
195
|
-
```
|
|
196
|
-
route.get('/users/:id')
|
|
197
|
-
.input({ params, query, body })
|
|
198
|
-
.handler(async (c) => { ... })
|
|
199
|
-
|
|
|
200
|
-
|-- Type Inference: RouteDef<TInput, TResponse>
|
|
201
|
-
|-- Validation: TypeBox schema
|
|
202
|
-
|-- Middleware: Skip control per route
|
|
203
|
-
|-- Response: c.success() / c.error()
|
|
204
|
-
```
|
|
205
|
-
|
|
206
|
-
**Key Components**:
|
|
207
|
-
|
|
208
|
-
- `defineRouter()`: Combines route definitions into typed router
|
|
209
|
-
- `route.get/post/put/patch/delete()`: Route builder with method chaining
|
|
210
|
-
- Input validation: Automatic TypeBox validation
|
|
211
|
-
- Middleware control: Per-route middleware skip
|
|
212
|
-
|
|
213
|
-
**Design Pattern**: Builder pattern with type inference
|
|
214
|
-
|
|
215
|
-
**[→ Full Documentation](./src/route/README.md)**
|
|
216
|
-
|
|
217
|
-
---
|
|
218
|
-
|
|
219
|
-
### 2. Server System (`src/server/`)
|
|
220
|
-
|
|
221
|
-
**Purpose**: HTTP server with lifecycle management
|
|
222
|
-
|
|
223
|
-
**Architecture**:
|
|
224
|
-
|
|
225
|
-
```
|
|
226
|
-
Configuration Sources (Priority Order):
|
|
227
|
-
1. Runtime config (startServer({ port: 3000 }))
|
|
228
|
-
2. server.config.ts (defineServerConfig().build())
|
|
229
|
-
3. Environment variables (PORT, DATABASE_URL)
|
|
230
|
-
4. Defaults
|
|
231
|
-
|
|
232
|
-
|
|
|
233
|
-
v
|
|
234
|
-
Middleware Pipeline:
|
|
235
|
-
1. Request Logger
|
|
236
|
-
2. CORS
|
|
237
|
-
3. Global Middlewares
|
|
238
|
-
4. Named Middlewares
|
|
239
|
-
5. beforeRoutes hook
|
|
240
|
-
6. Route Registration
|
|
241
|
-
7. afterRoutes hook
|
|
242
|
-
8. Route-specific Middlewares
|
|
243
|
-
9. Request Validation
|
|
244
|
-
10. Route Handler Execution
|
|
245
|
-
11. Response Serialization
|
|
246
|
-
12. Error Handler
|
|
247
|
-
|
|
248
|
-
|
|
|
249
|
-
v
|
|
250
|
-
Lifecycle Hooks:
|
|
251
|
-
- beforeInfrastructure
|
|
252
|
-
- afterInfrastructure
|
|
253
|
-
- beforeRoutes
|
|
254
|
-
- afterRoutes
|
|
255
|
-
- afterStart
|
|
256
|
-
- beforeShutdown
|
|
257
|
-
```
|
|
258
|
-
|
|
259
|
-
**Key Components**:
|
|
260
|
-
|
|
261
|
-
- `defineServerConfig()`: Fluent configuration builder
|
|
262
|
-
- `createServer()`: Creates Hono app with routes
|
|
263
|
-
- `startServer()`: Starts HTTP server with lifecycle
|
|
264
|
-
- Lifecycle hooks: beforeInfrastructure, afterInfrastructure, beforeRoutes, afterRoutes, afterStart, beforeShutdown
|
|
265
|
-
- Graceful shutdown: SIGTERM/SIGINT handling
|
|
266
|
-
|
|
267
|
-
**Design Pattern**: Builder + Lifecycle hooks
|
|
268
|
-
|
|
269
|
-
**[→ Full Documentation](./src/server/README.md)**
|
|
270
|
-
|
|
271
|
-
---
|
|
272
|
-
|
|
273
|
-
### 3. Database System (`src/db/`)
|
|
274
|
-
|
|
275
|
-
**Purpose**: Type-safe database operations with transactions
|
|
276
|
-
|
|
277
|
-
**Architecture**:
|
|
278
|
-
|
|
279
|
-
```
|
|
280
|
-
Application Code
|
|
281
|
-
|
|
|
282
|
-
| findOne(users, { id: 1 })
|
|
283
|
-
v
|
|
284
|
-
Helper Functions (Facade)
|
|
285
|
-
|
|
|
286
|
-
| Check AsyncLocalStorage for transaction
|
|
287
|
-
v
|
|
288
|
-
Transaction Context?
|
|
289
|
-
|-- Yes: Use transaction instance
|
|
290
|
-
|-- No: Use default database connection
|
|
291
|
-
|
|
|
292
|
-
v
|
|
293
|
-
Drizzle ORM Query Builder
|
|
294
|
-
|
|
|
295
|
-
v
|
|
296
|
-
PostgreSQL (Primary or Replica)
|
|
297
|
-
```
|
|
298
|
-
|
|
299
|
-
**Key Components**:
|
|
300
|
-
|
|
301
|
-
- Helper functions: `findOne`, `findMany`, `create`, `update`, `delete`, `count`
|
|
302
|
-
- Transaction middleware: `Transactional()` with AsyncLocalStorage propagation
|
|
303
|
-
- Read/Write separation: Automatic routing based on operation
|
|
304
|
-
- Schema helpers: `id()`, `timestamps()`, `foreignKey()`, `enumText()`
|
|
305
|
-
|
|
306
|
-
**Design Pattern**: Facade + AsyncLocalStorage for transaction propagation
|
|
307
|
-
|
|
308
|
-
**[→ Full Documentation](./src/db/README.md)**
|
|
309
|
-
|
|
310
|
-
**Transaction Flow**:
|
|
22
|
+
// src/server/entities/users.ts
|
|
23
|
+
import { pgTable, text, boolean } from 'drizzle-orm/pg-core';
|
|
24
|
+
import { id, timestamps } from '@spfn/core/db';
|
|
311
25
|
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
// Auto-commit on success
|
|
320
|
-
// Auto-rollback on error
|
|
321
|
-
return c.json(user);
|
|
26
|
+
export const users = pgTable('users', {
|
|
27
|
+
id: id(),
|
|
28
|
+
email: text('email').notNull().unique(),
|
|
29
|
+
name: text('name').notNull(),
|
|
30
|
+
isActive: boolean('is_active').notNull().default(true),
|
|
31
|
+
...timestamps()
|
|
322
32
|
});
|
|
323
|
-
```
|
|
324
|
-
|
|
325
|
-
---
|
|
326
|
-
|
|
327
|
-
### 4. Client System (`src/nextjs/`)
|
|
328
|
-
|
|
329
|
-
**Purpose**: Type-safe API client for Next.js with tRPC-style DX
|
|
330
|
-
|
|
331
|
-
**Architecture**:
|
|
332
33
|
|
|
34
|
+
export type User = typeof users.$inferSelect;
|
|
35
|
+
export type NewUser = typeof users.$inferInsert;
|
|
333
36
|
```
|
|
334
|
-
Client Code
|
|
335
|
-
|
|
|
336
|
-
| api.getUser.call({ params: { id: '123' } })
|
|
337
|
-
v
|
|
338
|
-
ApiClient (Proxy)
|
|
339
|
-
|
|
|
340
|
-
| body 유무로 GET/POST 결정
|
|
341
|
-
| GET /api/rpc/getUser?input={...}
|
|
342
|
-
| POST /api/rpc/createUser body: {...}
|
|
343
|
-
v
|
|
344
|
-
fetch() to Next.js API Route
|
|
345
|
-
|
|
|
346
|
-
v
|
|
347
|
-
RpcProxy (API Route Handler)
|
|
348
|
-
|
|
|
349
|
-
| 1. Extract routeName from URL
|
|
350
|
-
| 2. Lookup method/path from appRouter
|
|
351
|
-
| 3. Execute request interceptors
|
|
352
|
-
| 4. Forward to SPFN_API_URL with resolved path
|
|
353
|
-
| 5. Execute response interceptors
|
|
354
|
-
| 6. Set cookies from ctx.setCookies
|
|
355
|
-
v
|
|
356
|
-
Return NextResponse
|
|
357
|
-
```
|
|
358
|
-
|
|
359
|
-
**Key Components**:
|
|
360
|
-
|
|
361
|
-
- `createApi()`: tRPC-style client with type inference (no metadata needed)
|
|
362
|
-
- `createRpcProxy()`: RPC-style API Route handler with route resolution
|
|
363
|
-
- `registerInterceptors()`: Registry for plugin interceptors
|
|
364
|
-
- Path matching: Wildcard and param support (`/_auth/*`, `/users/:id`)
|
|
365
|
-
- Cookie handling: `setCookies` array in response interceptors
|
|
366
|
-
|
|
367
|
-
**Design Pattern**: Proxy for client, RPC resolution for proxy
|
|
368
|
-
|
|
369
|
-
**[→ Full Documentation](./src/nextjs/README.md)**
|
|
370
|
-
|
|
371
|
-
---
|
|
372
|
-
|
|
373
|
-
### 5. Error System (`src/errors/`)
|
|
374
|
-
|
|
375
|
-
**Purpose**: Structured error handling with HTTP status codes
|
|
376
|
-
|
|
377
|
-
**Key Components**:
|
|
378
|
-
|
|
379
|
-
- `ApiError`: Base error class
|
|
380
|
-
- `ValidationError`: Input validation failures (400)
|
|
381
|
-
- `NotFoundError`: Resource not found (404)
|
|
382
|
-
- `ConflictError`: Duplicate resources (409)
|
|
383
|
-
- `UnauthorizedError`: Authentication required (401)
|
|
384
|
-
|
|
385
|
-
**[→ Full Documentation](./src/errors/README.md)**
|
|
386
|
-
|
|
387
|
-
---
|
|
388
|
-
|
|
389
|
-
### 6. Logger System (`src/logger/`)
|
|
390
|
-
|
|
391
|
-
**Purpose**: Structured logging with transports and masking
|
|
392
|
-
|
|
393
|
-
**Key Components**:
|
|
394
|
-
|
|
395
|
-
- Adapter pattern: Pino (default) or custom
|
|
396
|
-
- Sensitive data masking: Passwords, tokens, API keys
|
|
397
|
-
- File rotation: Date and size-based with cleanup
|
|
398
|
-
- Multiple transports: Console, File, Slack, Email
|
|
399
37
|
|
|
400
|
-
|
|
38
|
+
### 2. Create Repository
|
|
401
39
|
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
**Purpose**: Valkey/Redis integration with master-replica support
|
|
407
|
-
|
|
408
|
-
**Key Components**:
|
|
409
|
-
|
|
410
|
-
- `initCache()`: Initialize connections
|
|
411
|
-
- `getCache()`: Write operations (master)
|
|
412
|
-
- `getCacheRead()`: Read operations (replica or master)
|
|
413
|
-
- `isCacheDisabled()`: Check if cache is disabled
|
|
414
|
-
- Graceful degradation when cache unavailable
|
|
415
|
-
|
|
416
|
-
**[→ Full Documentation](./src/cache/README.md)**
|
|
417
|
-
|
|
418
|
-
---
|
|
419
|
-
|
|
420
|
-
### 8. Middleware System (`src/middleware/`)
|
|
40
|
+
```typescript
|
|
41
|
+
// src/server/repositories/user.repository.ts
|
|
42
|
+
import { BaseRepository } from '@spfn/core/db';
|
|
43
|
+
import { users, type User, type NewUser } from '../entities/users';
|
|
421
44
|
|
|
422
|
-
|
|
45
|
+
export class UserRepository extends BaseRepository
|
|
46
|
+
{
|
|
47
|
+
async findById(id: string): Promise<User | null>
|
|
48
|
+
{
|
|
49
|
+
return this._findOne(users, { id });
|
|
50
|
+
}
|
|
423
51
|
|
|
424
|
-
|
|
52
|
+
async findByEmail(email: string): Promise<User | null>
|
|
53
|
+
{
|
|
54
|
+
return this._findOne(users, { email });
|
|
55
|
+
}
|
|
425
56
|
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
57
|
+
async findAll(): Promise<User[]>
|
|
58
|
+
{
|
|
59
|
+
return this._findMany(users);
|
|
60
|
+
}
|
|
429
61
|
|
|
430
|
-
|
|
62
|
+
async create(data: NewUser): Promise<User>
|
|
63
|
+
{
|
|
64
|
+
return this._create(users, data);
|
|
65
|
+
}
|
|
431
66
|
|
|
432
|
-
|
|
67
|
+
async update(id: string, data: Partial<NewUser>): Promise<User | null>
|
|
68
|
+
{
|
|
69
|
+
return this._updateOne(users, { id }, data);
|
|
70
|
+
}
|
|
433
71
|
|
|
434
|
-
|
|
72
|
+
async delete(id: string): Promise<User | null>
|
|
73
|
+
{
|
|
74
|
+
return this._deleteOne(users, { id });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
```
|
|
435
78
|
|
|
436
|
-
###
|
|
79
|
+
### 3. Define Routes
|
|
437
80
|
|
|
438
81
|
```typescript
|
|
439
|
-
// 1. Database Schema (Source of Truth)
|
|
440
|
-
// src/server/entities/users.ts
|
|
441
|
-
export const users = pgTable('users', {
|
|
442
|
-
id: id(),
|
|
443
|
-
name: text('name').notNull(),
|
|
444
|
-
email: text('email').notNull().unique(),
|
|
445
|
-
...timestamps(),
|
|
446
|
-
});
|
|
447
|
-
|
|
448
|
-
export type User = typeof users.$inferSelect;
|
|
449
|
-
// ^? { id: string; name: string; email: string; createdAt: Date; updatedAt: Date }
|
|
450
|
-
|
|
451
|
-
// 2. Server Route Definition
|
|
452
82
|
// src/server/routes/users.ts
|
|
453
83
|
import { route } from '@spfn/core/route';
|
|
84
|
+
import { Transactional } from '@spfn/core/db';
|
|
454
85
|
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
|
+
});
|
|
455
94
|
|
|
456
95
|
export const getUser = route.get('/users/:id')
|
|
457
96
|
.input({
|
|
458
|
-
params: Type.Object({
|
|
459
|
-
id: Type.String(),
|
|
460
|
-
}),
|
|
461
|
-
query: Type.Object({
|
|
462
|
-
include: Type.Optional(Type.String()),
|
|
463
|
-
}),
|
|
97
|
+
params: Type.Object({ id: Type.String() })
|
|
464
98
|
})
|
|
465
99
|
.handler(async (c) => {
|
|
466
|
-
const { params
|
|
467
|
-
|
|
100
|
+
const { params } = await c.data();
|
|
101
|
+
const user = await userRepo.findById(params.id);
|
|
468
102
|
|
|
469
|
-
|
|
470
|
-
|
|
103
|
+
if (!user)
|
|
104
|
+
{
|
|
105
|
+
throw new Error('User not found');
|
|
106
|
+
}
|
|
471
107
|
|
|
472
|
-
return
|
|
473
|
-
// ^? Response inferred from return value
|
|
108
|
+
return user;
|
|
474
109
|
});
|
|
475
110
|
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
})
|
|
111
|
+
export const createUser = route.post('/users')
|
|
112
|
+
.input({
|
|
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
|
+
});
|
|
482
123
|
|
|
483
|
-
export
|
|
484
|
-
|
|
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);
|
|
485
136
|
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
137
|
+
if (!user)
|
|
138
|
+
{
|
|
139
|
+
throw new Error('User not found');
|
|
140
|
+
}
|
|
489
141
|
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
.query({ include: 'posts' }) // Type-checked: { include?: string }
|
|
493
|
-
.call();
|
|
494
|
-
// ^? User (inferred from server handler return type)
|
|
142
|
+
return user;
|
|
143
|
+
});
|
|
495
144
|
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
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);
|
|
499
153
|
|
|
500
|
-
|
|
154
|
+
if (!user)
|
|
155
|
+
{
|
|
156
|
+
throw new Error('User not found');
|
|
157
|
+
}
|
|
501
158
|
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
type GetUserInput = InferRouteInput<typeof getUser>;
|
|
505
|
-
// ^? { params: { id: string }; query: { include?: string } }
|
|
506
|
-
|
|
507
|
-
// Extract output type from route
|
|
508
|
-
type GetUserOutput = InferRouteOutput<typeof getUser>;
|
|
509
|
-
// ^? User
|
|
510
|
-
|
|
511
|
-
// Client type inference
|
|
512
|
-
type ApiClient<TRouter> = {
|
|
513
|
-
[K in keyof TRouter['routes']]: TRouter['routes'][K] extends RouteDef<infer TInput, infer TOutput>
|
|
514
|
-
? RouteClient<TInput, TOutput>
|
|
515
|
-
: never;
|
|
516
|
-
};
|
|
159
|
+
return { success: true };
|
|
160
|
+
});
|
|
517
161
|
```
|
|
518
162
|
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
## Integration Points
|
|
522
|
-
|
|
523
|
-
### 1. Server ↔ Database: Transaction Context
|
|
524
|
-
|
|
525
|
-
**Mechanism**: AsyncLocalStorage propagates transaction across async calls
|
|
163
|
+
### 4. Configure Server
|
|
526
164
|
|
|
527
165
|
```typescript
|
|
528
|
-
//
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
const post = await create(posts, { title: 'Hello' });
|
|
532
|
-
|
|
533
|
-
// Same transaction is used automatically
|
|
534
|
-
const tag = await create(tags, { postId: post.id, name: 'news' });
|
|
166
|
+
// src/server/server.config.ts
|
|
167
|
+
import { defineServerConfig, defineRouter } from '@spfn/core/server';
|
|
168
|
+
import * as userRoutes from './routes/users';
|
|
535
169
|
|
|
536
|
-
|
|
537
|
-
|
|
170
|
+
const appRouter = defineRouter({
|
|
171
|
+
...userRoutes
|
|
538
172
|
});
|
|
539
|
-
```
|
|
540
|
-
|
|
541
|
-
**Why**: No need to pass transaction object through function calls
|
|
542
173
|
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
**Mechanism**: `typeof appRouter` captures all route types
|
|
174
|
+
export default defineServerConfig()
|
|
175
|
+
.port(8790)
|
|
176
|
+
.routes(appRouter)
|
|
177
|
+
.build();
|
|
548
178
|
|
|
549
|
-
```typescript
|
|
550
|
-
// Server: Export router type
|
|
551
|
-
export const appRouter = defineRouter({ getUser, createUser });
|
|
552
179
|
export type AppRouter = typeof appRouter;
|
|
553
|
-
|
|
554
|
-
// Client: Import type (not value!)
|
|
555
|
-
import type { AppRouter } from '@/server/router';
|
|
556
|
-
const api = createApi<AppRouter>(/* ... */);
|
|
557
|
-
|
|
558
|
-
// Automatic type inference for all routes
|
|
559
|
-
const user = await api.getUser.params({ id: '123' }).call();
|
|
560
|
-
// ^? Full type safety without manual type definitions
|
|
561
|
-
```
|
|
562
|
-
|
|
563
|
-
**Why**: Single source of truth (server) → client types automatically sync
|
|
564
|
-
|
|
565
|
-
---
|
|
566
|
-
|
|
567
|
-
### 3. Next.js ↔ SPFN: RPC Proxy
|
|
568
|
-
|
|
569
|
-
**Mechanism**: Next.js API Route resolves routeName and forwards to SPFN server
|
|
570
|
-
|
|
571
|
-
```typescript
|
|
572
|
-
// app/api/rpc/[routeName]/route.ts
|
|
573
|
-
import { appRouter } from '@/server/router';
|
|
574
|
-
import { createRpcProxy } from '@spfn/core/nextjs/server';
|
|
575
|
-
|
|
576
|
-
export const { GET, POST } = createRpcProxy({ router: appRouter });
|
|
577
|
-
|
|
578
|
-
// Automatically:
|
|
579
|
-
// 1. Resolves routeName → method/path from router
|
|
580
|
-
// 2. Forwards to http://localhost:8790/{resolved-path}
|
|
581
|
-
// 3. Applies interceptors (auth, cookies)
|
|
582
|
-
// 4. Returns NextResponse
|
|
583
180
|
```
|
|
584
181
|
|
|
585
|
-
|
|
586
|
-
- HttpOnly cookie support (browser → Next.js includes cookies automatically)
|
|
587
|
-
- Security (SPFN_API_URL hidden from browser)
|
|
588
|
-
- No CORS (same-origin requests)
|
|
589
|
-
- No metadata codegen required
|
|
590
|
-
|
|
591
|
-
---
|
|
592
|
-
|
|
593
|
-
### 4. Packages: Registry System
|
|
594
|
-
|
|
595
|
-
**Mechanism**: Packages register interceptors on import
|
|
182
|
+
### 5. Start Server
|
|
596
183
|
|
|
597
184
|
```typescript
|
|
598
|
-
//
|
|
599
|
-
import {
|
|
185
|
+
// src/server/index.ts
|
|
186
|
+
import { startServer } from '@spfn/core/server';
|
|
600
187
|
|
|
601
|
-
|
|
602
|
-
{
|
|
603
|
-
pathPattern: '/_auth/*',
|
|
604
|
-
response: async (ctx, next) => {
|
|
605
|
-
// Set session cookie
|
|
606
|
-
ctx.setCookies.push({ name: 'session', value: token });
|
|
607
|
-
await next();
|
|
608
|
-
},
|
|
609
|
-
},
|
|
610
|
-
]);
|
|
611
|
-
|
|
612
|
-
// App: Auto-discovery
|
|
613
|
-
import '@spfn/auth/adapters/nextjs'; // Registers interceptors
|
|
614
|
-
export { GET, POST } from '@spfn/core/nextjs/server'; // Uses registered interceptors
|
|
188
|
+
await startServer();
|
|
615
189
|
```
|
|
616
190
|
|
|
617
|
-
|
|
191
|
+
### 6. Environment Variables
|
|
618
192
|
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
### 1. Why tRPC-Style API over REST Client?
|
|
624
|
-
|
|
625
|
-
**Decision**: Method chaining with `.params().query().call()`
|
|
626
|
-
|
|
627
|
-
**Reasons**:
|
|
628
|
-
- Better DX: Fluent API guides usage
|
|
629
|
-
- Type safety: Each method is type-checked
|
|
630
|
-
- Flexibility: Optional parameters can be omitted
|
|
631
|
-
- Discovery: IDE autocomplete shows available options
|
|
632
|
-
|
|
633
|
-
**Example**:
|
|
634
|
-
```typescript
|
|
635
|
-
// tRPC-style (SPFN)
|
|
636
|
-
const user = await api.getUser
|
|
637
|
-
.params({ id: '123' })
|
|
638
|
-
.query({ include: 'posts' })
|
|
639
|
-
.call();
|
|
640
|
-
|
|
641
|
-
// vs Traditional REST client
|
|
642
|
-
const user = await client.get('/users/123', {
|
|
643
|
-
params: { include: 'posts' } // No type safety
|
|
644
|
-
});
|
|
193
|
+
```bash
|
|
194
|
+
# .env
|
|
195
|
+
DATABASE_URL=postgresql://localhost:5432/mydb
|
|
196
|
+
PORT=8790
|
|
645
197
|
```
|
|
646
198
|
|
|
647
199
|
---
|
|
648
200
|
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
**Decision**: Next.js API Route forwards to SPFN server
|
|
652
|
-
|
|
653
|
-
**Reasons**:
|
|
654
|
-
- **HttpOnly Cookies**: Browser automatically sends cookies to same-origin
|
|
655
|
-
- **Security**: SPFN API URL hidden from browser
|
|
656
|
-
- **No CORS**: Same-origin requests (localhost:3000 → localhost:3000)
|
|
657
|
-
- **Unified Path**: Server Components and Client Components use same API
|
|
658
|
-
|
|
659
|
-
**Alternative Rejected**: Direct calls from browser to SPFN server
|
|
660
|
-
- Would expose SPFN_API_URL to browser
|
|
661
|
-
- CORS configuration required
|
|
662
|
-
- Cannot use HttpOnly cookies from browser
|
|
663
|
-
|
|
664
|
-
---
|
|
665
|
-
|
|
666
|
-
### 3. Why define-route over File-Based Routing?
|
|
667
|
-
|
|
668
|
-
**Decision**: Explicit route imports with `defineRouter()`
|
|
669
|
-
|
|
670
|
-
**Reasons**:
|
|
671
|
-
- **Explicit Imports**: Better tree-shaking, clear dependencies
|
|
672
|
-
- **Type Safety**: `typeof appRouter` captures all routes
|
|
673
|
-
- **Flexibility**: Routes can be defined anywhere
|
|
674
|
-
- **No Magic**: No file system scanning at runtime
|
|
675
|
-
|
|
676
|
-
**Alternative Rejected**: File-based routing (like Next.js)
|
|
677
|
-
- Runtime file system scanning
|
|
678
|
-
- Implicit route registration
|
|
679
|
-
- Harder to trace route definitions
|
|
680
|
-
- Less flexible for monorepo setups
|
|
681
|
-
|
|
682
|
-
**Migration Path**: Deprecated contract-based and file-based routing systems
|
|
683
|
-
|
|
684
|
-
---
|
|
685
|
-
|
|
686
|
-
### 4. Why AsyncLocalStorage for Transactions?
|
|
687
|
-
|
|
688
|
-
**Decision**: Transaction propagation without explicit passing
|
|
201
|
+
## Architecture Overview
|
|
689
202
|
|
|
690
|
-
**Reasons**:
|
|
691
|
-
- **Clean API**: No need to pass `tx` object through all functions
|
|
692
|
-
- **Automatic**: Works across async boundaries
|
|
693
|
-
- **Safe**: Isolated per request
|
|
694
|
-
- **Compatible**: Works with existing code
|
|
695
|
-
|
|
696
|
-
**Example**:
|
|
697
|
-
```typescript
|
|
698
|
-
// With AsyncLocalStorage (SPFN)
|
|
699
|
-
async function createPostWithTags(data) {
|
|
700
|
-
const post = await create(posts, data);
|
|
701
|
-
const tags = await createMany(tags, data.tags.map(t => ({ postId: post.id, ...t })));
|
|
702
|
-
// Same transaction automatically
|
|
703
|
-
}
|
|
704
|
-
|
|
705
|
-
// vs Explicit transaction passing
|
|
706
|
-
async function createPostWithTags(tx, data) {
|
|
707
|
-
const post = await tx.insert(posts).values(data);
|
|
708
|
-
const tags = await tx.insert(tags).values(...);
|
|
709
|
-
// Must pass tx everywhere
|
|
710
|
-
}
|
|
711
203
|
```
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
### 6. Why TypeBox over Zod?
|
|
728
|
-
|
|
729
|
-
**Decision**: TypeBox for schema validation
|
|
730
|
-
|
|
731
|
-
**Reasons**:
|
|
732
|
-
- **JSON Schema**: Standard, interoperable
|
|
733
|
-
- **Performance**: Faster validation (JIT compilation)
|
|
734
|
-
- **OpenAPI**: Easy OpenAPI generation
|
|
735
|
-
- **Smaller**: Smaller bundle size
|
|
736
|
-
|
|
737
|
-
**Note**: Both are supported, TypeBox is the default
|
|
738
|
-
|
|
739
|
-
---
|
|
740
|
-
|
|
741
|
-
## Extension Points
|
|
742
|
-
|
|
743
|
-
### 1. Custom Middleware
|
|
744
|
-
|
|
745
|
-
Add global or route-specific middleware:
|
|
746
|
-
|
|
747
|
-
```typescript
|
|
748
|
-
// server.config.ts
|
|
749
|
-
import { defineServerConfig } from '@spfn/core/server';
|
|
750
|
-
import { defineMiddleware } from '@spfn/core/route';
|
|
751
|
-
|
|
752
|
-
const rateLimitMiddleware = defineMiddleware('rateLimit', async (c, next) => {
|
|
753
|
-
const ip = c.req.header('x-forwarded-for');
|
|
754
|
-
if (await isRateLimited(ip)) {
|
|
755
|
-
return c.json({ error: 'Rate limit exceeded' }, 429);
|
|
756
|
-
}
|
|
757
|
-
await next();
|
|
758
|
-
});
|
|
759
|
-
|
|
760
|
-
export default defineServerConfig()
|
|
761
|
-
.middlewares([rateLimitMiddleware])
|
|
762
|
-
.build();
|
|
763
|
-
|
|
764
|
-
// Route can skip middleware
|
|
765
|
-
export const publicRoute = route.get('/public')
|
|
766
|
-
.middleware({ skip: ['rateLimit'] })
|
|
767
|
-
.handler(async (c) => { ... });
|
|
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
|
+
└─────────────────────────────────────────┘
|
|
768
218
|
```
|
|
769
219
|
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
Add request/response interceptors:
|
|
775
|
-
|
|
776
|
-
```typescript
|
|
777
|
-
// app/api/rpc/[routeName]/route.ts
|
|
778
|
-
import { appRouter } from '@/server/router';
|
|
779
|
-
import { createRpcProxy } from '@spfn/core/nextjs/server';
|
|
780
|
-
|
|
781
|
-
export const { GET, POST } = createRpcProxy({
|
|
782
|
-
router: appRouter,
|
|
783
|
-
interceptors: [
|
|
784
|
-
{
|
|
785
|
-
pathPattern: '/admin/*',
|
|
786
|
-
request: async (ctx, next) => {
|
|
787
|
-
// Check admin role
|
|
788
|
-
const isAdmin = await checkAdminRole(ctx.cookies);
|
|
789
|
-
if (!isAdmin) {
|
|
790
|
-
throw new Error('Unauthorized');
|
|
791
|
-
}
|
|
792
|
-
await next();
|
|
793
|
-
},
|
|
794
|
-
},
|
|
795
|
-
],
|
|
796
|
-
});
|
|
797
|
-
```
|
|
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
|
|
798
224
|
|
|
799
225
|
---
|
|
800
226
|
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
Create SPFN packages with auto-discovery:
|
|
227
|
+
## Directory Structure
|
|
804
228
|
|
|
805
|
-
```typescript
|
|
806
|
-
// @yourcompany/spfn-analytics package
|
|
807
|
-
import { registerInterceptors } from '@spfn/core/nextjs/server';
|
|
808
|
-
|
|
809
|
-
registerInterceptors('analytics', [
|
|
810
|
-
{
|
|
811
|
-
pathPattern: '*',
|
|
812
|
-
response: async (ctx, next) => {
|
|
813
|
-
await analytics.track({
|
|
814
|
-
path: ctx.path,
|
|
815
|
-
status: ctx.response.status,
|
|
816
|
-
duration: Date.now() - ctx.metadata.startTime,
|
|
817
|
-
});
|
|
818
|
-
await next();
|
|
819
|
-
},
|
|
820
|
-
},
|
|
821
|
-
]);
|
|
822
|
-
|
|
823
|
-
// App: Auto-discovery
|
|
824
|
-
import '@yourcompany/spfn-analytics/adapters/nextjs';
|
|
825
|
-
export { GET, POST } from '@spfn/core/nextjs/server';
|
|
826
229
|
```
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
export async function findUserByEmail(email: string) {
|
|
840
|
-
return findOne(users, { email });
|
|
841
|
-
}
|
|
842
|
-
|
|
843
|
-
export async function createUserWithProfile(data) {
|
|
844
|
-
const user = await create(users, data.user);
|
|
845
|
-
const profile = await create(profiles, {
|
|
846
|
-
...data.profile,
|
|
847
|
-
userId: user.id,
|
|
848
|
-
});
|
|
849
|
-
return { user, profile };
|
|
850
|
-
}
|
|
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
|
|
851
242
|
```
|
|
852
243
|
|
|
853
244
|
---
|
|
854
245
|
|
|
855
|
-
##
|
|
246
|
+
## Core Concepts
|
|
856
247
|
|
|
857
|
-
###
|
|
248
|
+
### Route Definition
|
|
858
249
|
|
|
859
|
-
**Before** (Deprecated):
|
|
860
250
|
```typescript
|
|
861
|
-
// contract.ts
|
|
862
|
-
export const getUserContract = {
|
|
863
|
-
method: 'GET' as const,
|
|
864
|
-
path: '/users/:id',
|
|
865
|
-
params: Type.Object({ id: Type.String() }),
|
|
866
|
-
response: Type.Object({ id: Type.String(), name: Type.String() }),
|
|
867
|
-
} as const satisfies RouteContract;
|
|
868
|
-
|
|
869
|
-
// route.ts
|
|
870
|
-
import { createApp } from '@spfn/core/route';
|
|
871
|
-
const app = createApp();
|
|
872
|
-
app.bind(getUserContract, async (c) => { ... });
|
|
873
|
-
export default app;
|
|
874
|
-
```
|
|
875
|
-
|
|
876
|
-
**After** (Current):
|
|
877
|
-
```typescript
|
|
878
|
-
// routes/users.ts
|
|
879
251
|
import { route } from '@spfn/core/route';
|
|
252
|
+
import { Type } from '@sinclair/typebox';
|
|
880
253
|
|
|
881
|
-
|
|
254
|
+
// GET with params and query
|
|
255
|
+
route.get('/users/:id')
|
|
882
256
|
.input({
|
|
883
257
|
params: Type.Object({ id: Type.String() }),
|
|
258
|
+
query: Type.Object({ include: Type.Optional(Type.String()) })
|
|
884
259
|
})
|
|
885
260
|
.handler(async (c) => {
|
|
886
|
-
const { params } = await c.data();
|
|
887
|
-
|
|
261
|
+
const { params, query } = await c.data();
|
|
262
|
+
// params.id, query.include are fully typed
|
|
888
263
|
});
|
|
889
264
|
|
|
890
|
-
//
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
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
|
+
});
|
|
894
277
|
```
|
|
895
278
|
|
|
896
|
-
|
|
897
|
-
- Better type inference
|
|
898
|
-
- Explicit imports (tree-shaking)
|
|
899
|
-
- No separate contract files
|
|
900
|
-
|
|
901
|
-
---
|
|
279
|
+
### Repository Pattern
|
|
902
280
|
|
|
903
|
-
### From File-Based to define-route
|
|
904
|
-
|
|
905
|
-
**Before** (Deprecated):
|
|
906
|
-
```typescript
|
|
907
|
-
// routes/users/[id].ts
|
|
908
|
-
export default async function handler(c) { ... }
|
|
909
|
-
|
|
910
|
-
// Automatic file system scanning
|
|
911
|
-
```
|
|
912
|
-
|
|
913
|
-
**After** (Current):
|
|
914
281
|
```typescript
|
|
915
|
-
|
|
916
|
-
export const getUser = route.get('/users/:id')...
|
|
282
|
+
import { BaseRepository } from '@spfn/core/db';
|
|
917
283
|
|
|
918
|
-
|
|
919
|
-
|
|
284
|
+
export class UserRepository extends BaseRepository
|
|
285
|
+
{
|
|
286
|
+
// Protected helpers available:
|
|
287
|
+
// _findOne, _findMany, _create, _createMany
|
|
288
|
+
// _updateOne, _updateMany, _deleteOne, _deleteMany
|
|
289
|
+
// _count, _upsert
|
|
920
290
|
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
291
|
+
async findActive()
|
|
292
|
+
{
|
|
293
|
+
return this._findMany(users, {
|
|
294
|
+
where: { isActive: true },
|
|
295
|
+
orderBy: desc(users.createdAt),
|
|
296
|
+
limit: 10
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
}
|
|
925
300
|
```
|
|
926
301
|
|
|
927
|
-
|
|
928
|
-
- Explicit route registration
|
|
929
|
-
- Better IDE support
|
|
930
|
-
- No runtime file system scanning
|
|
931
|
-
|
|
932
|
-
---
|
|
933
|
-
|
|
934
|
-
### From ContractClient to tRPC-Style API
|
|
935
|
-
|
|
936
|
-
**Before** (Old Client):
|
|
937
|
-
```typescript
|
|
938
|
-
import { ContractClient } from '@spfn/core/client';
|
|
939
|
-
import { getUserContract } from '@/contracts/users';
|
|
940
|
-
|
|
941
|
-
const client = new ContractClient({ baseUrl: 'http://localhost:8790' });
|
|
942
|
-
const user = await client.call(getUserContract, {
|
|
943
|
-
params: { id: '123' },
|
|
944
|
-
});
|
|
945
|
-
```
|
|
302
|
+
### Transaction
|
|
946
303
|
|
|
947
|
-
**After** (Current):
|
|
948
304
|
```typescript
|
|
949
|
-
import {
|
|
305
|
+
import { Transactional } from '@spfn/core/db';
|
|
950
306
|
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
.
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
- Method chaining
|
|
959
|
-
- Better type inference
|
|
960
|
-
- Automatic cookie handling
|
|
961
|
-
|
|
962
|
-
---
|
|
963
|
-
|
|
964
|
-
## Module Exports
|
|
965
|
-
|
|
966
|
-
### Main Server Exports
|
|
967
|
-
|
|
968
|
-
```typescript
|
|
969
|
-
import {
|
|
970
|
-
startServer,
|
|
971
|
-
createServer,
|
|
972
|
-
defineServerConfig,
|
|
973
|
-
} from '@spfn/core';
|
|
974
|
-
```
|
|
307
|
+
// Middleware-based (recommended)
|
|
308
|
+
route.post('/users')
|
|
309
|
+
.use([Transactional()])
|
|
310
|
+
.handler(async (c) => {
|
|
311
|
+
// Auto commit on success
|
|
312
|
+
// Auto rollback on error
|
|
313
|
+
});
|
|
975
314
|
|
|
976
|
-
|
|
315
|
+
// Manual control
|
|
316
|
+
import { runWithTransaction } from '@spfn/core/db';
|
|
977
317
|
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
} from '@spfn/core/route';
|
|
983
|
-
|
|
984
|
-
import type {
|
|
985
|
-
RouteDef,
|
|
986
|
-
Router,
|
|
987
|
-
RouteInput,
|
|
988
|
-
} from '@spfn/core/route';
|
|
318
|
+
await runWithTransaction(async () => {
|
|
319
|
+
await userRepo.create(userData);
|
|
320
|
+
await profileRepo.create(profileData);
|
|
321
|
+
});
|
|
989
322
|
```
|
|
990
323
|
|
|
991
|
-
###
|
|
324
|
+
### Schema Helpers
|
|
992
325
|
|
|
993
326
|
```typescript
|
|
994
327
|
import {
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
deleteMany,
|
|
1004
|
-
count,
|
|
328
|
+
id, // bigserial primary key
|
|
329
|
+
uuid, // uuid primary key
|
|
330
|
+
timestamps, // createdAt, updatedAt
|
|
331
|
+
foreignKey, // required FK with cascade
|
|
332
|
+
optionalForeignKey, // nullable FK
|
|
333
|
+
softDelete, // deletedAt, deletedBy
|
|
334
|
+
enumText, // type-safe enum
|
|
335
|
+
typedJsonb // type-safe JSONB
|
|
1005
336
|
} from '@spfn/core/db';
|
|
1006
337
|
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
```typescript
|
|
1017
|
-
// Client-safe exports (works in Client Components)
|
|
1018
|
-
import {
|
|
1019
|
-
createApi,
|
|
1020
|
-
ApiError,
|
|
1021
|
-
} from '@spfn/core/nextjs';
|
|
1022
|
-
|
|
1023
|
-
// Server-only exports (API Routes)
|
|
1024
|
-
import {
|
|
1025
|
-
createRpcProxy,
|
|
1026
|
-
registerInterceptors,
|
|
1027
|
-
} from '@spfn/core/nextjs/server';
|
|
1028
|
-
```
|
|
1029
|
-
|
|
1030
|
-
### Error System
|
|
1031
|
-
|
|
1032
|
-
```typescript
|
|
1033
|
-
import {
|
|
1034
|
-
ApiError,
|
|
1035
|
-
ValidationError,
|
|
1036
|
-
NotFoundError,
|
|
1037
|
-
ConflictError,
|
|
1038
|
-
UnauthorizedError,
|
|
1039
|
-
} from '@spfn/core/errors';
|
|
1040
|
-
```
|
|
1041
|
-
|
|
1042
|
-
### Logger System
|
|
1043
|
-
|
|
1044
|
-
```typescript
|
|
1045
|
-
import { logger } from '@spfn/core';
|
|
1046
|
-
```
|
|
1047
|
-
|
|
1048
|
-
### Cache System
|
|
1049
|
-
|
|
1050
|
-
```typescript
|
|
1051
|
-
import {
|
|
1052
|
-
initCache,
|
|
1053
|
-
getCache,
|
|
1054
|
-
getCacheRead,
|
|
1055
|
-
isCacheDisabled,
|
|
1056
|
-
closeCache,
|
|
1057
|
-
} from '@spfn/core/cache';
|
|
1058
|
-
```
|
|
1059
|
-
|
|
1060
|
-
### Environment System
|
|
1061
|
-
|
|
1062
|
-
```typescript
|
|
1063
|
-
import {
|
|
1064
|
-
defineEnvSchema,
|
|
1065
|
-
createEnvRegistry,
|
|
1066
|
-
envString,
|
|
1067
|
-
envNumber,
|
|
1068
|
-
envBoolean,
|
|
1069
|
-
envEnum,
|
|
1070
|
-
} from '@spfn/core/env';
|
|
1071
|
-
```
|
|
1072
|
-
|
|
1073
|
-
### Configuration System
|
|
1074
|
-
|
|
1075
|
-
```typescript
|
|
1076
|
-
import { env, envSchema } from '@spfn/core/config';
|
|
338
|
+
export const posts = pgTable('posts', {
|
|
339
|
+
id: id(),
|
|
340
|
+
title: text('title').notNull(),
|
|
341
|
+
authorId: foreignKey('author', () => users.id),
|
|
342
|
+
status: enumText('status', ['draft', 'published']).default('draft'),
|
|
343
|
+
metadata: typedJsonb<{ views: number }>('metadata'),
|
|
344
|
+
...timestamps(),
|
|
345
|
+
...softDelete()
|
|
346
|
+
});
|
|
1077
347
|
```
|
|
1078
348
|
|
|
1079
349
|
---
|
|
1080
350
|
|
|
1081
|
-
##
|
|
1082
|
-
|
|
1083
|
-
### Environment Variables
|
|
351
|
+
## Module Documentation
|
|
1084
352
|
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
SPFN_API_URL=http://localhost:8790
|
|
1102
|
-
|
|
1103
|
-
# Server
|
|
1104
|
-
PORT=8790
|
|
1105
|
-
HOST=localhost
|
|
1106
|
-
NODE_ENV=development
|
|
1107
|
-
|
|
1108
|
-
# Server Timeouts (optional, milliseconds)
|
|
1109
|
-
SERVER_TIMEOUT=120000
|
|
1110
|
-
SERVER_KEEPALIVE_TIMEOUT=65000
|
|
1111
|
-
SERVER_HEADERS_TIMEOUT=60000
|
|
1112
|
-
SHUTDOWN_TIMEOUT=30000
|
|
1113
|
-
|
|
1114
|
-
# Logger (optional)
|
|
1115
|
-
LOGGER_ADAPTER=pino
|
|
1116
|
-
LOGGER_FILE_ENABLED=true
|
|
1117
|
-
LOG_DIR=/var/log/myapp
|
|
1118
|
-
```
|
|
353
|
+
| Module | Description |
|
|
354
|
+
|--------|-------------|
|
|
355
|
+
| [Route](./docs/route.md) | Route definition, validation, response patterns |
|
|
356
|
+
| [Database](./docs/database.md) | Connection, helpers, transactions |
|
|
357
|
+
| [Entity](./docs/entity.md) | Schema definition, column helpers |
|
|
358
|
+
| [Repository](./docs/repository.md) | BaseRepository, CRUD patterns |
|
|
359
|
+
| [Middleware](./docs/middleware.md) | Named middleware, skip control |
|
|
360
|
+
| [Server](./docs/server.md) | Configuration, lifecycle hooks |
|
|
361
|
+
| [Errors](./docs/errors.md) | Error types, handling patterns |
|
|
362
|
+
| [Environment](./docs/env.md) | Type-safe environment variables |
|
|
363
|
+
| [Codegen](./docs/codegen.md) | API client generation |
|
|
364
|
+
| [Cache](./docs/cache.md) | Redis caching |
|
|
365
|
+
| [Event](./docs/event.md) | Event system |
|
|
366
|
+
| [Job](./docs/job.md) | Background jobs |
|
|
367
|
+
| [Logger](./docs/logger.md) | Logging |
|
|
368
|
+
| [Next.js](./docs/nextjs.md) | Next.js integration, RPC proxy |
|
|
1119
369
|
|
|
1120
370
|
---
|
|
1121
371
|
|
|
1122
|
-
|
|
372
|
+
## CLI Commands
|
|
1123
373
|
|
|
1124
|
-
**1. Install**
|
|
1125
374
|
```bash
|
|
1126
|
-
|
|
1127
|
-
|
|
375
|
+
# Migration
|
|
376
|
+
npx spfn db generate # Generate migration
|
|
377
|
+
npx spfn db migrate # Apply migration
|
|
1128
378
|
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
// src/server/routes/users.ts
|
|
1132
|
-
export const getUser = route.get('/users/:id')
|
|
1133
|
-
.input({ params: Type.Object({ id: Type.String() }) })
|
|
1134
|
-
.handler(async (c) => {
|
|
1135
|
-
const { params } = await c.data();
|
|
1136
|
-
return c.success({ id: params.id, name: 'John' });
|
|
1137
|
-
});
|
|
1138
|
-
```
|
|
379
|
+
# Development
|
|
380
|
+
pnpm spfn:dev # Start dev server (auto codegen)
|
|
1139
381
|
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
// src/server/router.ts
|
|
1143
|
-
export const appRouter = defineRouter({ getUser });
|
|
1144
|
-
export type AppRouter = typeof appRouter;
|
|
1145
|
-
```
|
|
382
|
+
# API Client Generation
|
|
383
|
+
pnpm spfn codegen run
|
|
1146
384
|
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
// src/server/server.config.ts
|
|
1150
|
-
export default defineServerConfig()
|
|
1151
|
-
.port(8790)
|
|
1152
|
-
.routes(appRouter)
|
|
1153
|
-
.build();
|
|
1154
|
-
```
|
|
1155
|
-
|
|
1156
|
-
**5. Create RPC Proxy (Next.js)**
|
|
1157
|
-
```typescript
|
|
1158
|
-
// app/api/rpc/[routeName]/route.ts
|
|
1159
|
-
import { appRouter } from '@/server/router';
|
|
1160
|
-
import { createRpcProxy } from '@spfn/core/nextjs/server';
|
|
1161
|
-
|
|
1162
|
-
export const { GET, POST } = createRpcProxy({ router: appRouter });
|
|
1163
|
-
```
|
|
1164
|
-
|
|
1165
|
-
**6. Use Client**
|
|
1166
|
-
```typescript
|
|
1167
|
-
// app/users/[id]/page.tsx
|
|
1168
|
-
import { createApi } from '@spfn/core/nextjs';
|
|
1169
|
-
import type { AppRouter } from '@/server/router';
|
|
1170
|
-
|
|
1171
|
-
const api = createApi<AppRouter>();
|
|
1172
|
-
const user = await api.getUser.call({ params: { id: '123' } });
|
|
385
|
+
# Database Studio
|
|
386
|
+
pnpm drizzle-kit studio
|
|
1173
387
|
```
|
|
1174
388
|
|
|
1175
389
|
---
|
|
1176
390
|
|
|
1177
|
-
## Documentation
|
|
1178
|
-
|
|
1179
|
-
### Technical Architecture
|
|
1180
|
-
- [Route System](./src/route/README.md) - define-route system, type inference
|
|
1181
|
-
- [Server System](./src/server/README.md) - Configuration, middleware pipeline, lifecycle
|
|
1182
|
-
- [Database System](./src/db/README.md) - Helper functions, transactions, schema helpers
|
|
1183
|
-
- [Client System](./src/nextjs/README.md) - tRPC-style API, TypedProxy, interceptors
|
|
1184
|
-
|
|
1185
|
-
### Guides
|
|
1186
|
-
- [Transaction Management](./src/db/docs/transactions.md)
|
|
1187
|
-
- [Error Handling](./src/errors/README.md)
|
|
1188
|
-
- [Logger](./src/logger/README.md)
|
|
1189
|
-
- [Cache](./src/cache/README.md)
|
|
1190
|
-
- [Middleware](./src/middleware/README.md)
|
|
1191
|
-
- [Code Generation](./src/codegen/README.md)
|
|
1192
|
-
|
|
1193
|
-
---
|
|
1194
|
-
|
|
1195
|
-
## Requirements
|
|
1196
|
-
|
|
1197
|
-
- Node.js >= 18
|
|
1198
|
-
- Next.js 15+ with App Router (when using client integration)
|
|
1199
|
-
- PostgreSQL
|
|
1200
|
-
- Valkey/Redis (optional)
|
|
1201
|
-
- TypeScript >= 5.0
|
|
1202
|
-
|
|
1203
|
-
---
|
|
1204
|
-
|
|
1205
|
-
## Testing
|
|
1206
|
-
|
|
1207
|
-
```bash
|
|
1208
|
-
npm test # Run all tests
|
|
1209
|
-
npm test -- route # Run route tests only
|
|
1210
|
-
npm test -- --coverage # With coverage
|
|
1211
|
-
```
|
|
1212
|
-
|
|
1213
|
-
**Test Coverage**: 650+ tests across all modules
|
|
1214
|
-
|
|
1215
|
-
---
|
|
1216
|
-
|
|
1217
391
|
## License
|
|
1218
392
|
|
|
1219
393
|
MIT
|
|
1220
|
-
|
|
1221
|
-
---
|
|
1222
|
-
|
|
1223
|
-
Part of the [Superfunction Framework](https://github.com/spfn/spfn)
|