@spfn/core 0.2.0-beta.6 → 0.2.0-beta.8
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 +260 -1175
- package/dist/codegen/index.d.ts +47 -2
- package/dist/codegen/index.js +143 -5
- package/dist/codegen/index.js.map +1 -1
- package/dist/nextjs/index.d.ts +2 -2
- package/dist/nextjs/index.js +35 -3
- package/dist/nextjs/index.js.map +1 -1
- package/dist/nextjs/server.d.ts +60 -14
- package/dist/nextjs/server.js +97 -32
- package/dist/nextjs/server.js.map +1 -1
- package/dist/route/index.d.ts +136 -2
- package/dist/route/index.js +209 -11
- package/dist/route/index.js.map +1 -1
- package/dist/server/index.d.ts +71 -0
- package/dist/server/index.js +41 -0
- package/dist/server/index.js.map +1 -1
- package/dist/{types-D_N_U-Py.d.ts → types-BOPTApC2.d.ts} +15 -0
- 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 +1 -1
package/README.md
CHANGED
|
@@ -1,1308 +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
|
-
> **Beta Release**:
|
|
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
|
|
17
|
+
## Quick Start
|
|
59
18
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
## System Architecture
|
|
63
|
-
|
|
64
|
-
### High-Level Overview
|
|
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: Direct return / c.json() / helpers
|
|
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
|
-
|
|
333
|
-
```
|
|
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
|
-
|
|
400
|
-
**[→ Full Documentation](./src/logger/README.md)**
|
|
401
|
-
|
|
402
|
-
---
|
|
403
|
-
|
|
404
|
-
### 7. Cache System (`src/cache/`)
|
|
405
|
-
|
|
406
|
-
**Purpose**: Valkey/Redis integration with master-replica support
|
|
407
|
-
|
|
408
|
-
**Key Components**:
|
|
409
33
|
|
|
410
|
-
|
|
411
|
-
|
|
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/`)
|
|
421
|
-
|
|
422
|
-
**Purpose**: Named middleware with skip control
|
|
423
|
-
|
|
424
|
-
**Key Components**:
|
|
425
|
-
|
|
426
|
-
- `defineMiddleware()`: Create named middleware
|
|
427
|
-
- Skip control: Routes can skip specific middlewares
|
|
428
|
-
- Built-in: Logger, CORS, Error handler
|
|
429
|
-
|
|
430
|
-
**[→ Full Documentation](./src/middleware/README.md)**
|
|
431
|
-
|
|
432
|
-
---
|
|
433
|
-
|
|
434
|
-
### 9. Job System (`src/job/`)
|
|
435
|
-
|
|
436
|
-
**Purpose**: Background job processing with pg-boss
|
|
437
|
-
|
|
438
|
-
**Architecture**:
|
|
439
|
-
|
|
440
|
-
```
|
|
441
|
-
job('send-email')
|
|
442
|
-
.input(schema)
|
|
443
|
-
.options({ retryLimit: 3 })
|
|
444
|
-
.cron('0 9 * * *')
|
|
445
|
-
.on(eventDef)
|
|
446
|
-
.handler(async (input) => { ... })
|
|
34
|
+
export type User = typeof users.$inferSelect;
|
|
35
|
+
export type NewUser = typeof users.$inferInsert;
|
|
447
36
|
```
|
|
448
37
|
|
|
449
|
-
|
|
38
|
+
### 2. Create Repository
|
|
450
39
|
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
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';
|
|
455
44
|
|
|
456
|
-
|
|
45
|
+
export class UserRepository extends BaseRepository
|
|
46
|
+
{
|
|
47
|
+
async findById(id: string): Promise<User | null>
|
|
48
|
+
{
|
|
49
|
+
return this._findOne(users, { id });
|
|
50
|
+
}
|
|
457
51
|
|
|
458
|
-
|
|
52
|
+
async findByEmail(email: string): Promise<User | null>
|
|
53
|
+
{
|
|
54
|
+
return this._findOne(users, { email });
|
|
55
|
+
}
|
|
459
56
|
|
|
460
|
-
|
|
57
|
+
async findAll(): Promise<User[]>
|
|
58
|
+
{
|
|
59
|
+
return this._findMany(users);
|
|
60
|
+
}
|
|
461
61
|
|
|
462
|
-
|
|
62
|
+
async create(data: NewUser): Promise<User>
|
|
63
|
+
{
|
|
64
|
+
return this._create(users, data);
|
|
65
|
+
}
|
|
463
66
|
|
|
464
|
-
|
|
67
|
+
async update(id: string, data: Partial<NewUser>): Promise<User | null>
|
|
68
|
+
{
|
|
69
|
+
return this._updateOne(users, { id }, data);
|
|
70
|
+
}
|
|
465
71
|
|
|
72
|
+
async delete(id: string): Promise<User | null>
|
|
73
|
+
{
|
|
74
|
+
return this._deleteOne(users, { id });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
466
77
|
```
|
|
467
|
-
defineEvent('user.created', schema)
|
|
468
|
-
→ emit(payload)
|
|
469
|
-
→ handlers (in-memory)
|
|
470
|
-
→ job queues (pg-boss)
|
|
471
|
-
→ cache pub/sub (multi-instance)
|
|
472
|
-
```
|
|
473
|
-
|
|
474
|
-
**Key Components**:
|
|
475
|
-
|
|
476
|
-
- `defineEvent()`: Define typed events
|
|
477
|
-
- `event.emit()`: Emit event to all subscribers
|
|
478
|
-
- `event.subscribe()`: In-memory subscription
|
|
479
|
-
- `event.useCache()`: Multi-instance pub/sub via Redis
|
|
480
|
-
|
|
481
|
-
**Integration**: `job().on(event)` for event-driven jobs
|
|
482
78
|
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
## Type System
|
|
486
|
-
|
|
487
|
-
### End-to-End Type Safety Flow
|
|
79
|
+
### 3. Define Routes
|
|
488
80
|
|
|
489
81
|
```typescript
|
|
490
|
-
// 1. Database Schema (Source of Truth)
|
|
491
|
-
// src/server/entities/users.ts
|
|
492
|
-
export const users = pgTable('users', {
|
|
493
|
-
id: id(),
|
|
494
|
-
name: text('name').notNull(),
|
|
495
|
-
email: text('email').notNull().unique(),
|
|
496
|
-
...timestamps(),
|
|
497
|
-
});
|
|
498
|
-
|
|
499
|
-
export type User = typeof users.$inferSelect;
|
|
500
|
-
// ^? { id: string; name: string; email: string; createdAt: Date; updatedAt: Date }
|
|
501
|
-
|
|
502
|
-
// 2. Server Route Definition
|
|
503
82
|
// src/server/routes/users.ts
|
|
504
83
|
import { route } from '@spfn/core/route';
|
|
84
|
+
import { Transactional } from '@spfn/core/db';
|
|
505
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
|
+
});
|
|
506
94
|
|
|
507
95
|
export const getUser = route.get('/users/:id')
|
|
508
96
|
.input({
|
|
509
|
-
params: Type.Object({
|
|
510
|
-
id: Type.String(),
|
|
511
|
-
}),
|
|
512
|
-
query: Type.Object({
|
|
513
|
-
include: Type.Optional(Type.String()),
|
|
514
|
-
}),
|
|
97
|
+
params: Type.Object({ id: Type.String() })
|
|
515
98
|
})
|
|
516
99
|
.handler(async (c) => {
|
|
517
|
-
const { params
|
|
518
|
-
|
|
100
|
+
const { params } = await c.data();
|
|
101
|
+
const user = await userRepo.findById(params.id);
|
|
519
102
|
|
|
520
|
-
|
|
521
|
-
|
|
103
|
+
if (!user)
|
|
104
|
+
{
|
|
105
|
+
throw new Error('User not found');
|
|
106
|
+
}
|
|
522
107
|
|
|
523
108
|
return user;
|
|
524
|
-
// ^? Response type inferred from return value
|
|
525
109
|
});
|
|
526
110
|
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
})
|
|
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
|
+
});
|
|
533
123
|
|
|
534
|
-
export
|
|
535
|
-
|
|
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);
|
|
536
136
|
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
137
|
+
if (!user)
|
|
138
|
+
{
|
|
139
|
+
throw new Error('User not found');
|
|
140
|
+
}
|
|
540
141
|
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
.query({ include: 'posts' }) // Type-checked: { include?: string }
|
|
544
|
-
.call();
|
|
545
|
-
// ^? User (inferred from server handler return type)
|
|
142
|
+
return user;
|
|
143
|
+
});
|
|
546
144
|
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
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);
|
|
550
153
|
|
|
551
|
-
|
|
154
|
+
if (!user)
|
|
155
|
+
{
|
|
156
|
+
throw new Error('User not found');
|
|
157
|
+
}
|
|
552
158
|
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
type GetUserInput = InferRouteInput<typeof getUser>;
|
|
556
|
-
// ^? { params: { id: string }; query: { include?: string } }
|
|
557
|
-
|
|
558
|
-
// Extract output type from route
|
|
559
|
-
type GetUserOutput = InferRouteOutput<typeof getUser>;
|
|
560
|
-
// ^? User
|
|
561
|
-
|
|
562
|
-
// Client type inference
|
|
563
|
-
type ApiClient<TRouter> = {
|
|
564
|
-
[K in keyof TRouter['routes']]: TRouter['routes'][K] extends RouteDef<infer TInput, infer TOutput>
|
|
565
|
-
? RouteClient<TInput, TOutput>
|
|
566
|
-
: never;
|
|
567
|
-
};
|
|
159
|
+
return { success: true };
|
|
160
|
+
});
|
|
568
161
|
```
|
|
569
162
|
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
## Integration Points
|
|
573
|
-
|
|
574
|
-
### 1. Server ↔ Database: Transaction Context
|
|
575
|
-
|
|
576
|
-
**Mechanism**: AsyncLocalStorage propagates transaction across async calls
|
|
163
|
+
### 4. Configure Server
|
|
577
164
|
|
|
578
165
|
```typescript
|
|
579
|
-
//
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
const post = await create(posts, { title: 'Hello' });
|
|
583
|
-
|
|
584
|
-
// Same transaction is used automatically
|
|
585
|
-
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';
|
|
586
169
|
|
|
587
|
-
|
|
588
|
-
|
|
170
|
+
const appRouter = defineRouter({
|
|
171
|
+
...userRoutes
|
|
589
172
|
});
|
|
590
|
-
```
|
|
591
|
-
|
|
592
|
-
**Why**: No need to pass transaction object through function calls
|
|
593
173
|
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
**Mechanism**: `typeof appRouter` captures all route types
|
|
174
|
+
export default defineServerConfig()
|
|
175
|
+
.port(8790)
|
|
176
|
+
.routes(appRouter)
|
|
177
|
+
.build();
|
|
599
178
|
|
|
600
|
-
```typescript
|
|
601
|
-
// Server: Export router type
|
|
602
|
-
export const appRouter = defineRouter({ getUser, createUser });
|
|
603
179
|
export type AppRouter = typeof appRouter;
|
|
604
|
-
|
|
605
|
-
// Client: Import type (not value!)
|
|
606
|
-
import type { AppRouter } from '@/server/router';
|
|
607
|
-
const api = createApi<AppRouter>(/* ... */);
|
|
608
|
-
|
|
609
|
-
// Automatic type inference for all routes
|
|
610
|
-
const user = await api.getUser.params({ id: '123' }).call();
|
|
611
|
-
// ^? Full type safety without manual type definitions
|
|
612
180
|
```
|
|
613
181
|
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
---
|
|
617
|
-
|
|
618
|
-
### 3. Next.js ↔ SPFN: RPC Proxy
|
|
619
|
-
|
|
620
|
-
**Mechanism**: Next.js API Route resolves routeName and forwards to SPFN server
|
|
182
|
+
### 5. Start Server
|
|
621
183
|
|
|
622
184
|
```typescript
|
|
623
|
-
//
|
|
624
|
-
import {
|
|
625
|
-
import { createRpcProxy } from '@spfn/core/nextjs/server';
|
|
185
|
+
// src/server/index.ts
|
|
186
|
+
import { startServer } from '@spfn/core/server';
|
|
626
187
|
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
// Automatically:
|
|
630
|
-
// 1. Resolves routeName → method/path from router
|
|
631
|
-
// 2. Forwards to http://localhost:8790/{resolved-path}
|
|
632
|
-
// 3. Applies interceptors (auth, cookies)
|
|
633
|
-
// 4. Returns NextResponse
|
|
188
|
+
await startServer();
|
|
634
189
|
```
|
|
635
190
|
|
|
636
|
-
|
|
637
|
-
- HttpOnly cookie support (browser → Next.js includes cookies automatically)
|
|
638
|
-
- Security (SPFN_API_URL hidden from browser)
|
|
639
|
-
- No CORS (same-origin requests)
|
|
640
|
-
- No metadata codegen required
|
|
641
|
-
|
|
642
|
-
---
|
|
643
|
-
|
|
644
|
-
### 4. Packages: Registry System
|
|
645
|
-
|
|
646
|
-
**Mechanism**: Packages register interceptors on import
|
|
647
|
-
|
|
648
|
-
```typescript
|
|
649
|
-
// @spfn/auth package
|
|
650
|
-
import { registerInterceptors } from '@spfn/core/nextjs/server';
|
|
651
|
-
|
|
652
|
-
registerInterceptors('auth', [
|
|
653
|
-
{
|
|
654
|
-
pathPattern: '/_auth/*',
|
|
655
|
-
response: async (ctx, next) => {
|
|
656
|
-
// Set session cookie
|
|
657
|
-
ctx.setCookies.push({ name: 'session', value: token });
|
|
658
|
-
await next();
|
|
659
|
-
},
|
|
660
|
-
},
|
|
661
|
-
]);
|
|
662
|
-
|
|
663
|
-
// App: Auto-discovery
|
|
664
|
-
import '@spfn/auth/adapters/nextjs'; // Registers interceptors
|
|
665
|
-
export { GET, POST } from '@spfn/core/nextjs/server'; // Uses registered interceptors
|
|
666
|
-
```
|
|
667
|
-
|
|
668
|
-
**Why**: Zero-config integration for plugin packages
|
|
669
|
-
|
|
670
|
-
---
|
|
191
|
+
### 6. Environment Variables
|
|
671
192
|
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
**Decision**: Method chaining with `.params().query().call()`
|
|
677
|
-
|
|
678
|
-
**Reasons**:
|
|
679
|
-
- Better DX: Fluent API guides usage
|
|
680
|
-
- Type safety: Each method is type-checked
|
|
681
|
-
- Flexibility: Optional parameters can be omitted
|
|
682
|
-
- Discovery: IDE autocomplete shows available options
|
|
683
|
-
|
|
684
|
-
**Example**:
|
|
685
|
-
```typescript
|
|
686
|
-
// tRPC-style (SPFN)
|
|
687
|
-
const user = await api.getUser
|
|
688
|
-
.params({ id: '123' })
|
|
689
|
-
.query({ include: 'posts' })
|
|
690
|
-
.call();
|
|
691
|
-
|
|
692
|
-
// vs Traditional REST client
|
|
693
|
-
const user = await client.get('/users/123', {
|
|
694
|
-
params: { include: 'posts' } // No type safety
|
|
695
|
-
});
|
|
193
|
+
```bash
|
|
194
|
+
# .env
|
|
195
|
+
DATABASE_URL=postgresql://localhost:5432/mydb
|
|
196
|
+
PORT=8790
|
|
696
197
|
```
|
|
697
198
|
|
|
698
199
|
---
|
|
699
200
|
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
**Decision**: Next.js API Route forwards to SPFN server
|
|
703
|
-
|
|
704
|
-
**Reasons**:
|
|
705
|
-
- **HttpOnly Cookies**: Browser automatically sends cookies to same-origin
|
|
706
|
-
- **Security**: SPFN API URL hidden from browser
|
|
707
|
-
- **No CORS**: Same-origin requests (localhost:3000 → localhost:3000)
|
|
708
|
-
- **Unified Path**: Server Components and Client Components use same API
|
|
709
|
-
|
|
710
|
-
**Alternative Rejected**: Direct calls from browser to SPFN server
|
|
711
|
-
- Would expose SPFN_API_URL to browser
|
|
712
|
-
- CORS configuration required
|
|
713
|
-
- Cannot use HttpOnly cookies from browser
|
|
714
|
-
|
|
715
|
-
---
|
|
716
|
-
|
|
717
|
-
### 3. Why define-route over File-Based Routing?
|
|
201
|
+
## Architecture Overview
|
|
718
202
|
|
|
719
|
-
**Decision**: Explicit route imports with `defineRouter()`
|
|
720
|
-
|
|
721
|
-
**Reasons**:
|
|
722
|
-
- **Explicit Imports**: Better tree-shaking, clear dependencies
|
|
723
|
-
- **Type Safety**: `typeof appRouter` captures all routes
|
|
724
|
-
- **Flexibility**: Routes can be defined anywhere
|
|
725
|
-
- **No Magic**: No file system scanning at runtime
|
|
726
|
-
|
|
727
|
-
**Alternative Rejected**: File-based routing (like Next.js)
|
|
728
|
-
- Runtime file system scanning
|
|
729
|
-
- Implicit route registration
|
|
730
|
-
- Harder to trace route definitions
|
|
731
|
-
- Less flexible for monorepo setups
|
|
732
|
-
|
|
733
|
-
**Migration Path**: Deprecated contract-based and file-based routing systems
|
|
734
|
-
|
|
735
|
-
---
|
|
736
|
-
|
|
737
|
-
### 4. Why AsyncLocalStorage for Transactions?
|
|
738
|
-
|
|
739
|
-
**Decision**: Transaction propagation without explicit passing
|
|
740
|
-
|
|
741
|
-
**Reasons**:
|
|
742
|
-
- **Clean API**: No need to pass `tx` object through all functions
|
|
743
|
-
- **Automatic**: Works across async boundaries
|
|
744
|
-
- **Safe**: Isolated per request
|
|
745
|
-
- **Compatible**: Works with existing code
|
|
746
|
-
|
|
747
|
-
**Example**:
|
|
748
|
-
```typescript
|
|
749
|
-
// With AsyncLocalStorage (SPFN)
|
|
750
|
-
async function createPostWithTags(data) {
|
|
751
|
-
const post = await create(posts, data);
|
|
752
|
-
const tags = await createMany(tags, data.tags.map(t => ({ postId: post.id, ...t })));
|
|
753
|
-
// Same transaction automatically
|
|
754
|
-
}
|
|
755
|
-
|
|
756
|
-
// vs Explicit transaction passing
|
|
757
|
-
async function createPostWithTags(tx, data) {
|
|
758
|
-
const post = await tx.insert(posts).values(data);
|
|
759
|
-
const tags = await tx.insert(tags).values(...);
|
|
760
|
-
// Must pass tx everywhere
|
|
761
|
-
}
|
|
762
203
|
```
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
### 6. Why TypeBox over Zod?
|
|
779
|
-
|
|
780
|
-
**Decision**: TypeBox for schema validation
|
|
781
|
-
|
|
782
|
-
**Reasons**:
|
|
783
|
-
- **JSON Schema**: Standard, interoperable
|
|
784
|
-
- **Performance**: Faster validation (JIT compilation)
|
|
785
|
-
- **OpenAPI**: Easy OpenAPI generation
|
|
786
|
-
- **Smaller**: Smaller bundle size
|
|
787
|
-
|
|
788
|
-
**Note**: Both are supported, TypeBox is the default
|
|
789
|
-
|
|
790
|
-
---
|
|
791
|
-
|
|
792
|
-
## Extension Points
|
|
793
|
-
|
|
794
|
-
### 1. Custom Middleware
|
|
795
|
-
|
|
796
|
-
Add global or route-specific middleware:
|
|
797
|
-
|
|
798
|
-
```typescript
|
|
799
|
-
// server.config.ts
|
|
800
|
-
import { defineServerConfig } from '@spfn/core/server';
|
|
801
|
-
import { defineMiddleware } from '@spfn/core/route';
|
|
802
|
-
|
|
803
|
-
const rateLimitMiddleware = defineMiddleware('rateLimit', async (c, next) => {
|
|
804
|
-
const ip = c.req.header('x-forwarded-for');
|
|
805
|
-
if (await isRateLimited(ip)) {
|
|
806
|
-
return c.json({ error: 'Rate limit exceeded' }, 429);
|
|
807
|
-
}
|
|
808
|
-
await next();
|
|
809
|
-
});
|
|
810
|
-
|
|
811
|
-
export default defineServerConfig()
|
|
812
|
-
.middlewares([rateLimitMiddleware])
|
|
813
|
-
.build();
|
|
814
|
-
|
|
815
|
-
// Route can skip middleware
|
|
816
|
-
export const publicRoute = route.get('/public')
|
|
817
|
-
.middleware({ skip: ['rateLimit'] })
|
|
818
|
-
.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
|
+
└─────────────────────────────────────────┘
|
|
819
218
|
```
|
|
820
219
|
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
Add request/response interceptors:
|
|
826
|
-
|
|
827
|
-
```typescript
|
|
828
|
-
// app/api/rpc/[routeName]/route.ts
|
|
829
|
-
import { appRouter } from '@/server/router';
|
|
830
|
-
import { createRpcProxy } from '@spfn/core/nextjs/server';
|
|
831
|
-
|
|
832
|
-
export const { GET, POST } = createRpcProxy({
|
|
833
|
-
router: appRouter,
|
|
834
|
-
interceptors: [
|
|
835
|
-
{
|
|
836
|
-
pathPattern: '/admin/*',
|
|
837
|
-
request: async (ctx, next) => {
|
|
838
|
-
// Check admin role
|
|
839
|
-
const isAdmin = await checkAdminRole(ctx.cookies);
|
|
840
|
-
if (!isAdmin) {
|
|
841
|
-
throw new Error('Unauthorized');
|
|
842
|
-
}
|
|
843
|
-
await next();
|
|
844
|
-
},
|
|
845
|
-
},
|
|
846
|
-
],
|
|
847
|
-
});
|
|
848
|
-
```
|
|
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
|
|
849
224
|
|
|
850
225
|
---
|
|
851
226
|
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
Create SPFN packages with auto-discovery:
|
|
855
|
-
|
|
856
|
-
```typescript
|
|
857
|
-
// @yourcompany/spfn-analytics package
|
|
858
|
-
import { registerInterceptors } from '@spfn/core/nextjs/server';
|
|
227
|
+
## Directory Structure
|
|
859
228
|
|
|
860
|
-
registerInterceptors('analytics', [
|
|
861
|
-
{
|
|
862
|
-
pathPattern: '*',
|
|
863
|
-
response: async (ctx, next) => {
|
|
864
|
-
await analytics.track({
|
|
865
|
-
path: ctx.path,
|
|
866
|
-
status: ctx.response.status,
|
|
867
|
-
duration: Date.now() - ctx.metadata.startTime,
|
|
868
|
-
});
|
|
869
|
-
await next();
|
|
870
|
-
},
|
|
871
|
-
},
|
|
872
|
-
]);
|
|
873
|
-
|
|
874
|
-
// App: Auto-discovery
|
|
875
|
-
import '@yourcompany/spfn-analytics/adapters/nextjs';
|
|
876
|
-
export { GET, POST } from '@spfn/core/nextjs/server';
|
|
877
229
|
```
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
export async function findUserByEmail(email: string) {
|
|
891
|
-
return findOne(users, { email });
|
|
892
|
-
}
|
|
893
|
-
|
|
894
|
-
export async function createUserWithProfile(data) {
|
|
895
|
-
const user = await create(users, data.user);
|
|
896
|
-
const profile = await create(profiles, {
|
|
897
|
-
...data.profile,
|
|
898
|
-
userId: user.id,
|
|
899
|
-
});
|
|
900
|
-
return { user, profile };
|
|
901
|
-
}
|
|
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
|
|
902
242
|
```
|
|
903
243
|
|
|
904
244
|
---
|
|
905
245
|
|
|
906
|
-
##
|
|
907
|
-
|
|
908
|
-
### From Contract-Based to define-route
|
|
246
|
+
## Core Concepts
|
|
909
247
|
|
|
910
|
-
|
|
911
|
-
```typescript
|
|
912
|
-
// contract.ts
|
|
913
|
-
export const getUserContract = {
|
|
914
|
-
method: 'GET' as const,
|
|
915
|
-
path: '/users/:id',
|
|
916
|
-
params: Type.Object({ id: Type.String() }),
|
|
917
|
-
response: Type.Object({ id: Type.String(), name: Type.String() }),
|
|
918
|
-
} as const satisfies RouteContract;
|
|
919
|
-
|
|
920
|
-
// route.ts
|
|
921
|
-
import { createApp } from '@spfn/core/route';
|
|
922
|
-
const app = createApp();
|
|
923
|
-
app.bind(getUserContract, async (c) => { ... });
|
|
924
|
-
export default app;
|
|
925
|
-
```
|
|
248
|
+
### Route Definition
|
|
926
249
|
|
|
927
|
-
**After** (Current):
|
|
928
250
|
```typescript
|
|
929
|
-
// routes/users.ts
|
|
930
251
|
import { route } from '@spfn/core/route';
|
|
252
|
+
import { Type } from '@sinclair/typebox';
|
|
931
253
|
|
|
932
|
-
|
|
254
|
+
// GET with params and query
|
|
255
|
+
route.get('/users/:id')
|
|
933
256
|
.input({
|
|
934
257
|
params: Type.Object({ id: Type.String() }),
|
|
258
|
+
query: Type.Object({ include: Type.Optional(Type.String()) })
|
|
935
259
|
})
|
|
936
260
|
.handler(async (c) => {
|
|
937
|
-
const { params } = await c.data();
|
|
938
|
-
|
|
261
|
+
const { params, query } = await c.data();
|
|
262
|
+
// params.id, query.include are fully typed
|
|
939
263
|
});
|
|
940
264
|
|
|
941
|
-
//
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
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
|
+
});
|
|
945
277
|
```
|
|
946
278
|
|
|
947
|
-
|
|
948
|
-
- Better type inference
|
|
949
|
-
- Explicit imports (tree-shaking)
|
|
950
|
-
- No separate contract files
|
|
951
|
-
|
|
952
|
-
---
|
|
953
|
-
|
|
954
|
-
### From File-Based to define-route
|
|
279
|
+
### Repository Pattern
|
|
955
280
|
|
|
956
|
-
**Before** (Deprecated):
|
|
957
281
|
```typescript
|
|
958
|
-
|
|
959
|
-
export default async function handler(c) { ... }
|
|
282
|
+
import { BaseRepository } from '@spfn/core/db';
|
|
960
283
|
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
//
|
|
967
|
-
export const getUser = route.get('/users/:id')...
|
|
968
|
-
|
|
969
|
-
// router.ts
|
|
970
|
-
export const appRouter = defineRouter({ getUser });
|
|
284
|
+
export class UserRepository extends BaseRepository
|
|
285
|
+
{
|
|
286
|
+
// Protected helpers available:
|
|
287
|
+
// _findOne, _findMany, _create, _createMany
|
|
288
|
+
// _updateOne, _updateMany, _deleteOne, _deleteMany
|
|
289
|
+
// _count, _upsert
|
|
971
290
|
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
291
|
+
async findActive()
|
|
292
|
+
{
|
|
293
|
+
return this._findMany(users, {
|
|
294
|
+
where: { isActive: true },
|
|
295
|
+
orderBy: desc(users.createdAt),
|
|
296
|
+
limit: 10
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
}
|
|
976
300
|
```
|
|
977
301
|
|
|
978
|
-
|
|
979
|
-
- Explicit route registration
|
|
980
|
-
- Better IDE support
|
|
981
|
-
- No runtime file system scanning
|
|
302
|
+
### Transaction
|
|
982
303
|
|
|
983
|
-
---
|
|
984
|
-
|
|
985
|
-
### From ContractClient to tRPC-Style API
|
|
986
|
-
|
|
987
|
-
**Before** (Old Client):
|
|
988
304
|
```typescript
|
|
989
|
-
import {
|
|
990
|
-
import { getUserContract } from '@/contracts/users';
|
|
991
|
-
|
|
992
|
-
const client = new ContractClient({ baseUrl: 'http://localhost:8790' });
|
|
993
|
-
const user = await client.call(getUserContract, {
|
|
994
|
-
params: { id: '123' },
|
|
995
|
-
});
|
|
996
|
-
```
|
|
997
|
-
|
|
998
|
-
**After** (Current):
|
|
999
|
-
```typescript
|
|
1000
|
-
import { api } from '@spfn/core/nextjs/server';
|
|
1001
|
-
|
|
1002
|
-
const user = await api.getUser
|
|
1003
|
-
.params({ id: '123' })
|
|
1004
|
-
.call();
|
|
1005
|
-
```
|
|
305
|
+
import { Transactional } from '@spfn/core/db';
|
|
1006
306
|
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
## Module Exports
|
|
1016
|
-
|
|
1017
|
-
### Main Server Exports
|
|
1018
|
-
|
|
1019
|
-
```typescript
|
|
1020
|
-
import {
|
|
1021
|
-
startServer,
|
|
1022
|
-
createServer,
|
|
1023
|
-
defineServerConfig,
|
|
1024
|
-
} from '@spfn/core';
|
|
1025
|
-
```
|
|
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
|
+
});
|
|
1026
314
|
|
|
1027
|
-
|
|
315
|
+
// Manual control
|
|
316
|
+
import { runWithTransaction } from '@spfn/core/db';
|
|
1028
317
|
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
} from '@spfn/core/route';
|
|
1034
|
-
|
|
1035
|
-
import type {
|
|
1036
|
-
RouteDef,
|
|
1037
|
-
Router,
|
|
1038
|
-
RouteInput,
|
|
1039
|
-
} from '@spfn/core/route';
|
|
318
|
+
await runWithTransaction(async () => {
|
|
319
|
+
await userRepo.create(userData);
|
|
320
|
+
await profileRepo.create(profileData);
|
|
321
|
+
});
|
|
1040
322
|
```
|
|
1041
323
|
|
|
1042
|
-
###
|
|
324
|
+
### Schema Helpers
|
|
1043
325
|
|
|
1044
326
|
```typescript
|
|
1045
327
|
import {
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
deleteMany,
|
|
1055
|
-
count,
|
|
1056
|
-
} from '@spfn/core/db';
|
|
1057
|
-
|
|
1058
|
-
import {
|
|
1059
|
-
Transactional,
|
|
1060
|
-
getTransaction,
|
|
1061
|
-
runWithTransaction,
|
|
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
|
|
1062
336
|
} from '@spfn/core/db';
|
|
1063
|
-
```
|
|
1064
|
-
|
|
1065
|
-
### Client System
|
|
1066
|
-
|
|
1067
|
-
```typescript
|
|
1068
|
-
// Client-safe exports (works in Client Components)
|
|
1069
|
-
import {
|
|
1070
|
-
createApi,
|
|
1071
|
-
ApiError,
|
|
1072
|
-
} from '@spfn/core/nextjs';
|
|
1073
337
|
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
```typescript
|
|
1084
|
-
import {
|
|
1085
|
-
ApiError,
|
|
1086
|
-
ValidationError,
|
|
1087
|
-
NotFoundError,
|
|
1088
|
-
ConflictError,
|
|
1089
|
-
UnauthorizedError,
|
|
1090
|
-
} from '@spfn/core/errors';
|
|
1091
|
-
```
|
|
1092
|
-
|
|
1093
|
-
### Logger System
|
|
1094
|
-
|
|
1095
|
-
```typescript
|
|
1096
|
-
import { logger } from '@spfn/core';
|
|
1097
|
-
```
|
|
1098
|
-
|
|
1099
|
-
### Cache System
|
|
1100
|
-
|
|
1101
|
-
```typescript
|
|
1102
|
-
import {
|
|
1103
|
-
initCache,
|
|
1104
|
-
getCache,
|
|
1105
|
-
getCacheRead,
|
|
1106
|
-
isCacheDisabled,
|
|
1107
|
-
closeCache,
|
|
1108
|
-
} from '@spfn/core/cache';
|
|
1109
|
-
```
|
|
1110
|
-
|
|
1111
|
-
### Environment System
|
|
1112
|
-
|
|
1113
|
-
```typescript
|
|
1114
|
-
import {
|
|
1115
|
-
defineEnvSchema,
|
|
1116
|
-
createEnvRegistry,
|
|
1117
|
-
envString,
|
|
1118
|
-
envNumber,
|
|
1119
|
-
envBoolean,
|
|
1120
|
-
envEnum,
|
|
1121
|
-
} from '@spfn/core/env';
|
|
1122
|
-
```
|
|
1123
|
-
|
|
1124
|
-
### Configuration System
|
|
1125
|
-
|
|
1126
|
-
```typescript
|
|
1127
|
-
import { env, envSchema } from '@spfn/core/config';
|
|
1128
|
-
```
|
|
1129
|
-
|
|
1130
|
-
### Job System
|
|
1131
|
-
|
|
1132
|
-
```typescript
|
|
1133
|
-
import {
|
|
1134
|
-
job,
|
|
1135
|
-
defineJobRouter,
|
|
1136
|
-
registerJobs,
|
|
1137
|
-
initBoss,
|
|
1138
|
-
getBoss,
|
|
1139
|
-
stopBoss,
|
|
1140
|
-
} from '@spfn/core/job';
|
|
1141
|
-
|
|
1142
|
-
import type {
|
|
1143
|
-
JobDef,
|
|
1144
|
-
JobRouter,
|
|
1145
|
-
JobOptions,
|
|
1146
|
-
BossOptions,
|
|
1147
|
-
InferJobInput,
|
|
1148
|
-
} from '@spfn/core/job';
|
|
1149
|
-
```
|
|
1150
|
-
|
|
1151
|
-
### Event System
|
|
1152
|
-
|
|
1153
|
-
```typescript
|
|
1154
|
-
import { defineEvent } from '@spfn/core/event';
|
|
1155
|
-
|
|
1156
|
-
import type {
|
|
1157
|
-
EventDef,
|
|
1158
|
-
EventHandler,
|
|
1159
|
-
InferEventPayload,
|
|
1160
|
-
PubSubCache,
|
|
1161
|
-
} from '@spfn/core/event';
|
|
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
|
+
});
|
|
1162
347
|
```
|
|
1163
348
|
|
|
1164
349
|
---
|
|
1165
350
|
|
|
1166
|
-
##
|
|
1167
|
-
|
|
1168
|
-
### Environment Variables
|
|
1169
|
-
|
|
1170
|
-
```bash
|
|
1171
|
-
# Database (required)
|
|
1172
|
-
DATABASE_URL=postgresql://user:pass@localhost:5432/db
|
|
1173
|
-
|
|
1174
|
-
# Database Read Replica (optional)
|
|
1175
|
-
DATABASE_READ_URL=postgresql://user:pass@replica:5432/db
|
|
1176
|
-
|
|
1177
|
-
# Cache - Valkey/Redis (optional)
|
|
1178
|
-
CACHE_URL=redis://localhost:6379
|
|
1179
|
-
CACHE_WRITE_URL=redis://master:6379
|
|
1180
|
-
CACHE_READ_URL=redis://replica:6379
|
|
351
|
+
## Module Documentation
|
|
1181
352
|
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
# Logger (optional)
|
|
1200
|
-
LOGGER_ADAPTER=pino
|
|
1201
|
-
LOGGER_FILE_ENABLED=true
|
|
1202
|
-
LOG_DIR=/var/log/myapp
|
|
1203
|
-
```
|
|
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 |
|
|
1204
369
|
|
|
1205
370
|
---
|
|
1206
371
|
|
|
1207
|
-
|
|
372
|
+
## CLI Commands
|
|
1208
373
|
|
|
1209
|
-
**1. Install**
|
|
1210
374
|
```bash
|
|
1211
|
-
|
|
1212
|
-
|
|
375
|
+
# Migration
|
|
376
|
+
npx spfn db generate # Generate migration
|
|
377
|
+
npx spfn db migrate # Apply migration
|
|
1213
378
|
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
// src/server/routes/users.ts
|
|
1217
|
-
export const getUser = route.get('/users/:id')
|
|
1218
|
-
.input({ params: Type.Object({ id: Type.String() }) })
|
|
1219
|
-
.handler(async (c) => {
|
|
1220
|
-
const { params } = await c.data();
|
|
1221
|
-
return { id: params.id, name: 'John' };
|
|
1222
|
-
});
|
|
1223
|
-
```
|
|
379
|
+
# Development
|
|
380
|
+
pnpm spfn:dev # Start dev server (auto codegen)
|
|
1224
381
|
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
// src/server/router.ts
|
|
1228
|
-
export const appRouter = defineRouter({ getUser });
|
|
1229
|
-
export type AppRouter = typeof appRouter;
|
|
1230
|
-
```
|
|
382
|
+
# API Client Generation
|
|
383
|
+
pnpm spfn codegen run
|
|
1231
384
|
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
// src/server/server.config.ts
|
|
1235
|
-
export default defineServerConfig()
|
|
1236
|
-
.port(8790)
|
|
1237
|
-
.routes(appRouter)
|
|
1238
|
-
.build();
|
|
1239
|
-
```
|
|
1240
|
-
|
|
1241
|
-
**5. Create RPC Proxy (Next.js)**
|
|
1242
|
-
```typescript
|
|
1243
|
-
// app/api/rpc/[routeName]/route.ts
|
|
1244
|
-
import { appRouter } from '@/server/router';
|
|
1245
|
-
import { createRpcProxy } from '@spfn/core/nextjs/server';
|
|
1246
|
-
|
|
1247
|
-
export const { GET, POST } = createRpcProxy({ router: appRouter });
|
|
1248
|
-
```
|
|
1249
|
-
|
|
1250
|
-
**6. Use Client**
|
|
1251
|
-
```typescript
|
|
1252
|
-
// app/users/[id]/page.tsx
|
|
1253
|
-
import { createApi } from '@spfn/core/nextjs';
|
|
1254
|
-
import type { AppRouter } from '@/server/router';
|
|
1255
|
-
|
|
1256
|
-
const api = createApi<AppRouter>();
|
|
1257
|
-
const user = await api.getUser.call({ params: { id: '123' } });
|
|
385
|
+
# Database Studio
|
|
386
|
+
pnpm drizzle-kit studio
|
|
1258
387
|
```
|
|
1259
388
|
|
|
1260
389
|
---
|
|
1261
390
|
|
|
1262
|
-
## Documentation
|
|
1263
|
-
|
|
1264
|
-
### Technical Architecture
|
|
1265
|
-
- [Route System](./src/route/README.md) - define-route system, type inference
|
|
1266
|
-
- [Server System](./src/server/README.md) - Configuration, middleware pipeline, lifecycle
|
|
1267
|
-
- [Database System](./src/db/README.md) - Helper functions, transactions, schema helpers
|
|
1268
|
-
- [Client System](./src/nextjs/README.md) - tRPC-style API, TypedProxy, interceptors
|
|
1269
|
-
|
|
1270
|
-
### Guides
|
|
1271
|
-
- [Transaction Management](./src/db/docs/transactions.md)
|
|
1272
|
-
- [Error Handling](./src/errors/README.md)
|
|
1273
|
-
- [Logger](./src/logger/README.md)
|
|
1274
|
-
- [Cache](./src/cache/README.md)
|
|
1275
|
-
- [Middleware](./src/middleware/README.md)
|
|
1276
|
-
- [Code Generation](./src/codegen/README.md)
|
|
1277
|
-
|
|
1278
|
-
---
|
|
1279
|
-
|
|
1280
|
-
## Requirements
|
|
1281
|
-
|
|
1282
|
-
- Node.js >= 18
|
|
1283
|
-
- Next.js 15+ with App Router (when using client integration)
|
|
1284
|
-
- PostgreSQL
|
|
1285
|
-
- Valkey/Redis (optional)
|
|
1286
|
-
- TypeScript >= 5.0
|
|
1287
|
-
|
|
1288
|
-
---
|
|
1289
|
-
|
|
1290
|
-
## Testing
|
|
1291
|
-
|
|
1292
|
-
```bash
|
|
1293
|
-
npm test # Run all tests
|
|
1294
|
-
npm test -- route # Run route tests only
|
|
1295
|
-
npm test -- --coverage # With coverage
|
|
1296
|
-
```
|
|
1297
|
-
|
|
1298
|
-
**Test Coverage**: 650+ tests across all modules
|
|
1299
|
-
|
|
1300
|
-
---
|
|
1301
|
-
|
|
1302
391
|
## License
|
|
1303
392
|
|
|
1304
393
|
MIT
|
|
1305
|
-
|
|
1306
|
-
---
|
|
1307
|
-
|
|
1308
|
-
Part of the [Superfunction Framework](https://github.com/spfn/spfn)
|