@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/docs/nextjs.md
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
# Next.js Integration
|
|
2
|
+
|
|
3
|
+
RPC proxy and type-safe API client for Next.js.
|
|
4
|
+
|
|
5
|
+
## Setup
|
|
6
|
+
|
|
7
|
+
### 1. Create RPC Proxy
|
|
8
|
+
|
|
9
|
+
```typescript
|
|
10
|
+
// app/api/rpc/[routeName]/route.ts
|
|
11
|
+
import { appRouter } from '@/server/server.config';
|
|
12
|
+
import { createRpcProxy } from '@spfn/core/nextjs/server';
|
|
13
|
+
|
|
14
|
+
export const { GET, POST, PUT, PATCH, DELETE } = createRpcProxy({
|
|
15
|
+
router: appRouter,
|
|
16
|
+
apiUrl: process.env.SPFN_API_URL || 'http://localhost:8790'
|
|
17
|
+
});
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
### 2. Create API Client
|
|
21
|
+
|
|
22
|
+
```typescript
|
|
23
|
+
// src/lib/api.ts
|
|
24
|
+
import { createApi } from '@spfn/core/nextjs';
|
|
25
|
+
import type { AppRouter } from '@/server/server.config';
|
|
26
|
+
|
|
27
|
+
export const api = createApi<AppRouter>();
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Usage
|
|
31
|
+
|
|
32
|
+
### Server Components
|
|
33
|
+
|
|
34
|
+
```typescript
|
|
35
|
+
// app/users/[id]/page.tsx
|
|
36
|
+
import { api } from '@/lib/api';
|
|
37
|
+
|
|
38
|
+
export default async function UserPage({ params }: { params: { id: string } })
|
|
39
|
+
{
|
|
40
|
+
const user = await api.getUser.call({
|
|
41
|
+
params: { id: params.id }
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
return <div>{user.name}</div>;
|
|
45
|
+
}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### Client Components
|
|
49
|
+
|
|
50
|
+
```typescript
|
|
51
|
+
'use client';
|
|
52
|
+
|
|
53
|
+
import { api } from '@/lib/api';
|
|
54
|
+
import { useState } from 'react';
|
|
55
|
+
|
|
56
|
+
export function CreateUserForm()
|
|
57
|
+
{
|
|
58
|
+
const [loading, setLoading] = useState(false);
|
|
59
|
+
|
|
60
|
+
async function handleSubmit(formData: FormData)
|
|
61
|
+
{
|
|
62
|
+
setLoading(true);
|
|
63
|
+
try
|
|
64
|
+
{
|
|
65
|
+
await api.createUser.call({
|
|
66
|
+
body: {
|
|
67
|
+
email: formData.get('email') as string,
|
|
68
|
+
name: formData.get('name') as string
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
finally
|
|
73
|
+
{
|
|
74
|
+
setLoading(false);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<form action={handleSubmit}>
|
|
80
|
+
{/* ... */}
|
|
81
|
+
</form>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### Server Actions
|
|
87
|
+
|
|
88
|
+
```typescript
|
|
89
|
+
// app/actions.ts
|
|
90
|
+
'use server';
|
|
91
|
+
|
|
92
|
+
import { api } from '@/lib/api';
|
|
93
|
+
|
|
94
|
+
export async function createUser(formData: FormData)
|
|
95
|
+
{
|
|
96
|
+
const user = await api.createUser.call({
|
|
97
|
+
body: {
|
|
98
|
+
email: formData.get('email') as string,
|
|
99
|
+
name: formData.get('name') as string
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
return user;
|
|
104
|
+
}
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## API Client Methods
|
|
108
|
+
|
|
109
|
+
```typescript
|
|
110
|
+
// Call with params
|
|
111
|
+
const user = await api.getUser.call({
|
|
112
|
+
params: { id: '123' }
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// Call with query
|
|
116
|
+
const users = await api.getUsers.call({
|
|
117
|
+
query: { page: 1, limit: 20, search: 'john' }
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// Call with body
|
|
121
|
+
const created = await api.createUser.call({
|
|
122
|
+
body: { email: 'user@example.com', name: 'User' }
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// Call with multiple inputs
|
|
126
|
+
const updated = await api.updateUser.call({
|
|
127
|
+
params: { id: '123' },
|
|
128
|
+
body: { name: 'Updated Name' }
|
|
129
|
+
});
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
## Interceptors
|
|
133
|
+
|
|
134
|
+
### Request Interceptor
|
|
135
|
+
|
|
136
|
+
```typescript
|
|
137
|
+
export const { GET, POST } = createRpcProxy({
|
|
138
|
+
router: appRouter,
|
|
139
|
+
apiUrl: process.env.SPFN_API_URL,
|
|
140
|
+
interceptors: {
|
|
141
|
+
request: async (request, context) => {
|
|
142
|
+
// Add auth header
|
|
143
|
+
const token = cookies().get('token')?.value;
|
|
144
|
+
if (token)
|
|
145
|
+
{
|
|
146
|
+
request.headers.set('Authorization', `Bearer ${token}`);
|
|
147
|
+
}
|
|
148
|
+
return request;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
### Response Interceptor
|
|
155
|
+
|
|
156
|
+
```typescript
|
|
157
|
+
interceptors: {
|
|
158
|
+
response: async (response, context) => {
|
|
159
|
+
// Handle Set-Cookie from API
|
|
160
|
+
const setCookie = response.headers.get('set-cookie');
|
|
161
|
+
if (setCookie)
|
|
162
|
+
{
|
|
163
|
+
cookies().set(parseCookie(setCookie));
|
|
164
|
+
}
|
|
165
|
+
return response;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
## Cookie Handling
|
|
171
|
+
|
|
172
|
+
The RPC proxy automatically handles HttpOnly cookies:
|
|
173
|
+
|
|
174
|
+
```typescript
|
|
175
|
+
// Server sets cookie
|
|
176
|
+
c.header('Set-Cookie', 'session=abc; HttpOnly; Secure');
|
|
177
|
+
|
|
178
|
+
// Proxy forwards to browser
|
|
179
|
+
// Browser stores HttpOnly cookie
|
|
180
|
+
// Subsequent requests include cookie automatically
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
## Error Handling
|
|
184
|
+
|
|
185
|
+
```typescript
|
|
186
|
+
try
|
|
187
|
+
{
|
|
188
|
+
const user = await api.getUser.call({ params: { id: '123' } });
|
|
189
|
+
}
|
|
190
|
+
catch (error)
|
|
191
|
+
{
|
|
192
|
+
if (error.status === 404)
|
|
193
|
+
{
|
|
194
|
+
// Not found
|
|
195
|
+
}
|
|
196
|
+
else if (error.status === 401)
|
|
197
|
+
{
|
|
198
|
+
// Unauthorized
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
## Environment Variables
|
|
204
|
+
|
|
205
|
+
```bash
|
|
206
|
+
# API server URL
|
|
207
|
+
SPFN_API_URL=http://localhost:8790
|
|
208
|
+
|
|
209
|
+
# For production
|
|
210
|
+
SPFN_API_URL=https://api.example.com
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
## Best Practices
|
|
214
|
+
|
|
215
|
+
```typescript
|
|
216
|
+
// 1. Create single api instance
|
|
217
|
+
// src/lib/api.ts
|
|
218
|
+
export const api = createApi<AppRouter>();
|
|
219
|
+
|
|
220
|
+
// 2. Use in Server Components for SSR
|
|
221
|
+
export default async function Page() {
|
|
222
|
+
const data = await api.getData.call({}); // SSR
|
|
223
|
+
return <div>{data}</div>;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// 3. Handle loading states in Client Components
|
|
227
|
+
const [loading, setLoading] = useState(false);
|
|
228
|
+
|
|
229
|
+
// 4. Use Server Actions for mutations
|
|
230
|
+
'use server';
|
|
231
|
+
export async function createItem(formData: FormData) {
|
|
232
|
+
return api.createItem.call({ body: { ... } });
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// 5. Type-safe error handling
|
|
236
|
+
try {
|
|
237
|
+
await api.getUser.call({ params: { id } });
|
|
238
|
+
} catch (e) {
|
|
239
|
+
if (e.status === 404) redirect('/not-found');
|
|
240
|
+
}
|
|
241
|
+
```
|
|
@@ -0,0 +1,496 @@
|
|
|
1
|
+
# Repository
|
|
2
|
+
|
|
3
|
+
Data access layer with BaseRepository class and domain-specific patterns.
|
|
4
|
+
|
|
5
|
+
## Basic Repository
|
|
6
|
+
|
|
7
|
+
```typescript
|
|
8
|
+
// src/server/repositories/user.repository.ts
|
|
9
|
+
import { BaseRepository } from '@spfn/core/db';
|
|
10
|
+
import { users, type User, type NewUser } from '../entities/users';
|
|
11
|
+
|
|
12
|
+
export class UserRepository extends BaseRepository
|
|
13
|
+
{
|
|
14
|
+
async findById(id: string): Promise<User | null>
|
|
15
|
+
{
|
|
16
|
+
return this._findOne(users, { id });
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async findByEmail(email: string): Promise<User | null>
|
|
20
|
+
{
|
|
21
|
+
return this._findOne(users, { email });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async findAll(): Promise<User[]>
|
|
25
|
+
{
|
|
26
|
+
return this._findMany(users);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async create(data: NewUser): Promise<User>
|
|
30
|
+
{
|
|
31
|
+
return this._create(users, data);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async update(id: string, data: Partial<NewUser>): Promise<User | null>
|
|
35
|
+
{
|
|
36
|
+
return this._updateOne(users, { id }, data);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async delete(id: string): Promise<User | null>
|
|
40
|
+
{
|
|
41
|
+
return this._deleteOne(users, { id });
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Export singleton instance
|
|
46
|
+
export const userRepo = new UserRepository();
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
## Export Patterns
|
|
52
|
+
|
|
53
|
+
### Singleton Instance (Recommended)
|
|
54
|
+
|
|
55
|
+
```typescript
|
|
56
|
+
// src/server/repositories/user.repository.ts
|
|
57
|
+
export class UserRepository extends BaseRepository
|
|
58
|
+
{
|
|
59
|
+
// ... methods
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Export singleton instance
|
|
63
|
+
export const userRepo = new UserRepository();
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
**Usage in routes:**
|
|
67
|
+
|
|
68
|
+
```typescript
|
|
69
|
+
// src/server/routes/users.ts
|
|
70
|
+
import { userRepo } from '../repositories/user.repository';
|
|
71
|
+
|
|
72
|
+
export const getUser = route.get('/users/:id')
|
|
73
|
+
.handler(async (c) => {
|
|
74
|
+
const { params } = await c.data();
|
|
75
|
+
return userRepo.findById(params.id);
|
|
76
|
+
});
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### Index File Export
|
|
80
|
+
|
|
81
|
+
```typescript
|
|
82
|
+
// src/server/repositories/index.ts
|
|
83
|
+
export { userRepo } from './user.repository';
|
|
84
|
+
export { postRepo } from './post.repository';
|
|
85
|
+
export { categoryRepo } from './category.repository';
|
|
86
|
+
|
|
87
|
+
// Optional: also export classes for testing
|
|
88
|
+
export { UserRepository } from './user.repository';
|
|
89
|
+
export { PostRepository } from './post.repository';
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
**Usage:**
|
|
93
|
+
|
|
94
|
+
```typescript
|
|
95
|
+
import { userRepo, postRepo } from '../repositories';
|
|
96
|
+
|
|
97
|
+
const user = await userRepo.findById(id);
|
|
98
|
+
const posts = await postRepo.findByAuthor(user.id);
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### Why Singleton?
|
|
102
|
+
|
|
103
|
+
- **Transaction propagation**: BaseRepository uses AsyncLocalStorage to detect active transactions
|
|
104
|
+
- **No manual DB passing**: Instance automatically uses correct connection
|
|
105
|
+
- **Testable**: Can still instantiate class directly in tests
|
|
106
|
+
|
|
107
|
+
```typescript
|
|
108
|
+
// In tests - create fresh instance
|
|
109
|
+
const testRepo = new UserRepository();
|
|
110
|
+
|
|
111
|
+
// In application - use singleton
|
|
112
|
+
import { userRepo } from '../repositories';
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
---
|
|
116
|
+
|
|
117
|
+
## Protected Helper Methods
|
|
118
|
+
|
|
119
|
+
BaseRepository provides these protected methods:
|
|
120
|
+
|
|
121
|
+
| Method | Description | Returns |
|
|
122
|
+
|--------|-------------|---------|
|
|
123
|
+
| `_findOne(table, where)` | Find single record | `T \| null` |
|
|
124
|
+
| `_findMany(table, options?)` | Find multiple records | `T[]` |
|
|
125
|
+
| `_create(table, data)` | Create single record | `T` |
|
|
126
|
+
| `_createMany(table, data[])` | Create multiple records | `T[]` |
|
|
127
|
+
| `_upsert(table, data, options)` | Insert or update | `T` |
|
|
128
|
+
| `_updateOne(table, where, data)` | Update single record | `T \| null` |
|
|
129
|
+
| `_updateMany(table, where, data)` | Update multiple records | `T[]` |
|
|
130
|
+
| `_deleteOne(table, where)` | Delete single record | `T \| null` |
|
|
131
|
+
| `_deleteMany(table, where)` | Delete multiple records | `T[]` |
|
|
132
|
+
| `_count(table, where?)` | Count records | `number` |
|
|
133
|
+
|
|
134
|
+
---
|
|
135
|
+
|
|
136
|
+
## Where Clause
|
|
137
|
+
|
|
138
|
+
### Object-based (Simple Equality)
|
|
139
|
+
|
|
140
|
+
```typescript
|
|
141
|
+
// Single field
|
|
142
|
+
await this._findOne(users, { id: '1' });
|
|
143
|
+
|
|
144
|
+
// Multiple fields (AND)
|
|
145
|
+
await this._findOne(users, { email: 'test@example.com', isActive: true });
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
### SQL-based (Complex Conditions)
|
|
149
|
+
|
|
150
|
+
```typescript
|
|
151
|
+
import { eq, and, or, gt, lt, like, isNull, inArray } from 'drizzle-orm';
|
|
152
|
+
|
|
153
|
+
// Complex AND
|
|
154
|
+
await this._findMany(users, {
|
|
155
|
+
where: and(
|
|
156
|
+
eq(users.isActive, true),
|
|
157
|
+
gt(users.createdAt, lastWeek)
|
|
158
|
+
)
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// OR condition
|
|
162
|
+
await this._findMany(users, {
|
|
163
|
+
where: or(
|
|
164
|
+
eq(users.role, 'admin'),
|
|
165
|
+
eq(users.role, 'moderator')
|
|
166
|
+
)
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// LIKE
|
|
170
|
+
await this._findMany(users, {
|
|
171
|
+
where: like(users.email, '%@example.com')
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// IN
|
|
175
|
+
await this._findMany(users, {
|
|
176
|
+
where: inArray(users.id, ['1', '2', '3'])
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
// IS NULL
|
|
180
|
+
await this._findMany(users, {
|
|
181
|
+
where: isNull(users.deletedAt)
|
|
182
|
+
});
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
---
|
|
186
|
+
|
|
187
|
+
## Query Options
|
|
188
|
+
|
|
189
|
+
### Ordering
|
|
190
|
+
|
|
191
|
+
```typescript
|
|
192
|
+
import { desc, asc } from 'drizzle-orm';
|
|
193
|
+
|
|
194
|
+
await this._findMany(users, {
|
|
195
|
+
orderBy: desc(users.createdAt)
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
// Multiple columns
|
|
199
|
+
await this._findMany(users, {
|
|
200
|
+
orderBy: [desc(users.createdAt), asc(users.name)]
|
|
201
|
+
});
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
### Pagination
|
|
205
|
+
|
|
206
|
+
```typescript
|
|
207
|
+
await this._findMany(users, {
|
|
208
|
+
limit: 20,
|
|
209
|
+
offset: 40 // Skip first 40
|
|
210
|
+
});
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
### Combined
|
|
214
|
+
|
|
215
|
+
```typescript
|
|
216
|
+
await this._findMany(users, {
|
|
217
|
+
where: eq(users.isActive, true),
|
|
218
|
+
orderBy: desc(users.createdAt),
|
|
219
|
+
limit: 10,
|
|
220
|
+
offset: 0
|
|
221
|
+
});
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
---
|
|
225
|
+
|
|
226
|
+
## Business Logic Patterns
|
|
227
|
+
|
|
228
|
+
### Validation in Create
|
|
229
|
+
|
|
230
|
+
```typescript
|
|
231
|
+
export class UserRepository extends BaseRepository
|
|
232
|
+
{
|
|
233
|
+
async createWithValidation(data: NewUser): Promise<User>
|
|
234
|
+
{
|
|
235
|
+
// Check duplicate
|
|
236
|
+
const existing = await this._findOne(users, { email: data.email });
|
|
237
|
+
if (existing)
|
|
238
|
+
{
|
|
239
|
+
throw new Error('Email already exists');
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return this._create(users, data);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
### Soft Delete
|
|
248
|
+
|
|
249
|
+
```typescript
|
|
250
|
+
export class PostRepository extends BaseRepository
|
|
251
|
+
{
|
|
252
|
+
async softDelete(id: string, deletedBy: string): Promise<Post | null>
|
|
253
|
+
{
|
|
254
|
+
return this._updateOne(posts, { id }, {
|
|
255
|
+
deletedAt: new Date(),
|
|
256
|
+
deletedBy
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async findActive(): Promise<Post[]>
|
|
261
|
+
{
|
|
262
|
+
return this._findMany(posts, {
|
|
263
|
+
where: isNull(posts.deletedAt)
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
### Paginated Query
|
|
270
|
+
|
|
271
|
+
```typescript
|
|
272
|
+
export class UserRepository extends BaseRepository
|
|
273
|
+
{
|
|
274
|
+
async findPaginated(page: number, limit: number)
|
|
275
|
+
{
|
|
276
|
+
const offset = (page - 1) * limit;
|
|
277
|
+
|
|
278
|
+
const [items, total] = await Promise.all([
|
|
279
|
+
this._findMany(users, {
|
|
280
|
+
orderBy: desc(users.createdAt),
|
|
281
|
+
limit,
|
|
282
|
+
offset
|
|
283
|
+
}),
|
|
284
|
+
this._count(users)
|
|
285
|
+
]);
|
|
286
|
+
|
|
287
|
+
return {
|
|
288
|
+
items,
|
|
289
|
+
total,
|
|
290
|
+
page,
|
|
291
|
+
limit,
|
|
292
|
+
totalPages: Math.ceil(total / limit)
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
### Filtered Query
|
|
299
|
+
|
|
300
|
+
```typescript
|
|
301
|
+
export class UserRepository extends BaseRepository
|
|
302
|
+
{
|
|
303
|
+
async findByFilters(filters: {
|
|
304
|
+
role?: string;
|
|
305
|
+
isActive?: boolean;
|
|
306
|
+
search?: string;
|
|
307
|
+
}): Promise<User[]>
|
|
308
|
+
{
|
|
309
|
+
const conditions = [];
|
|
310
|
+
|
|
311
|
+
if (filters.role)
|
|
312
|
+
{
|
|
313
|
+
conditions.push(eq(users.role, filters.role));
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if (filters.isActive !== undefined)
|
|
317
|
+
{
|
|
318
|
+
conditions.push(eq(users.isActive, filters.isActive));
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if (filters.search)
|
|
322
|
+
{
|
|
323
|
+
conditions.push(or(
|
|
324
|
+
like(users.name, `%${filters.search}%`),
|
|
325
|
+
like(users.email, `%${filters.search}%`)
|
|
326
|
+
));
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
return this._findMany(users, {
|
|
330
|
+
where: conditions.length > 0 ? and(...conditions) : undefined,
|
|
331
|
+
orderBy: desc(users.createdAt)
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
---
|
|
338
|
+
|
|
339
|
+
## Complex Queries
|
|
340
|
+
|
|
341
|
+
For queries that can't be expressed with helpers, use direct database access.
|
|
342
|
+
|
|
343
|
+
```typescript
|
|
344
|
+
export class UserRepository extends BaseRepository
|
|
345
|
+
{
|
|
346
|
+
async findWithPostCounts(): Promise<Array<User & { postCount: number }>>
|
|
347
|
+
{
|
|
348
|
+
return this.readDb
|
|
349
|
+
.select({
|
|
350
|
+
...getTableColumns(users),
|
|
351
|
+
postCount: sql<number>`count(${posts.id})::int`
|
|
352
|
+
})
|
|
353
|
+
.from(users)
|
|
354
|
+
.leftJoin(posts, eq(users.id, posts.authorId))
|
|
355
|
+
.where(eq(users.isActive, true))
|
|
356
|
+
.groupBy(users.id)
|
|
357
|
+
.orderBy(desc(sql`count(${posts.id})`));
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
async findUsersWithRecentPosts(since: Date)
|
|
361
|
+
{
|
|
362
|
+
return this.readDb
|
|
363
|
+
.selectDistinct({ user: users })
|
|
364
|
+
.from(users)
|
|
365
|
+
.innerJoin(posts, eq(users.id, posts.authorId))
|
|
366
|
+
.where(gt(posts.createdAt, since));
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
### Database Access
|
|
372
|
+
|
|
373
|
+
```typescript
|
|
374
|
+
export class UserRepository extends BaseRepository
|
|
375
|
+
{
|
|
376
|
+
// Protected getters from BaseRepository:
|
|
377
|
+
// this.db - Write database (transaction-aware)
|
|
378
|
+
// this.readDb - Read database (uses replica if available)
|
|
379
|
+
|
|
380
|
+
async customQuery()
|
|
381
|
+
{
|
|
382
|
+
// Use readDb for SELECT
|
|
383
|
+
const results = await this.readDb
|
|
384
|
+
.select()
|
|
385
|
+
.from(users)
|
|
386
|
+
.where(...);
|
|
387
|
+
|
|
388
|
+
// Use db for INSERT/UPDATE/DELETE
|
|
389
|
+
await this.db
|
|
390
|
+
.insert(users)
|
|
391
|
+
.values(data);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
---
|
|
397
|
+
|
|
398
|
+
## Error Handling
|
|
399
|
+
|
|
400
|
+
### withContext
|
|
401
|
+
|
|
402
|
+
Wrap operations with context for better error tracking.
|
|
403
|
+
|
|
404
|
+
```typescript
|
|
405
|
+
export class UserRepository extends BaseRepository
|
|
406
|
+
{
|
|
407
|
+
async findById(id: string)
|
|
408
|
+
{
|
|
409
|
+
return this.withContext(
|
|
410
|
+
() => this._findOne(users, { id }),
|
|
411
|
+
{ method: 'findById', table: 'users' }
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// On error: RepositoryError with repository name, method, table context
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
---
|
|
420
|
+
|
|
421
|
+
## Repository Export
|
|
422
|
+
|
|
423
|
+
```typescript
|
|
424
|
+
// src/server/repositories/index.ts
|
|
425
|
+
export { UserRepository } from './user.repository';
|
|
426
|
+
export { PostRepository } from './post.repository';
|
|
427
|
+
export { CategoryRepository } from './category.repository';
|
|
428
|
+
```
|
|
429
|
+
|
|
430
|
+
---
|
|
431
|
+
|
|
432
|
+
## Best Practices
|
|
433
|
+
|
|
434
|
+
### Do
|
|
435
|
+
|
|
436
|
+
```typescript
|
|
437
|
+
// 1. Encapsulate business logic
|
|
438
|
+
async createWithValidation(data: NewUser)
|
|
439
|
+
{
|
|
440
|
+
const existing = await this._findOne(users, { email: data.email });
|
|
441
|
+
if (existing) throw new Error('Email exists');
|
|
442
|
+
return this._create(users, data);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// 2. Use object-based where for simple queries
|
|
446
|
+
await this._findOne(users, { id });
|
|
447
|
+
|
|
448
|
+
// 3. Use SQL-based where for complex queries
|
|
449
|
+
await this._findMany(users, {
|
|
450
|
+
where: and(eq(users.role, 'admin'), gt(users.createdAt, date))
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
// 4. Use readDb for read operations
|
|
454
|
+
async findAll()
|
|
455
|
+
{
|
|
456
|
+
return this.readDb.select().from(users);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// 5. Create domain-specific methods
|
|
460
|
+
async findActiveAdmins()
|
|
461
|
+
{
|
|
462
|
+
return this._findMany(users, {
|
|
463
|
+
where: and(eq(users.role, 'admin'), eq(users.isActive, true))
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
```
|
|
467
|
+
|
|
468
|
+
### Don't
|
|
469
|
+
|
|
470
|
+
```typescript
|
|
471
|
+
// 1. Don't expose protected methods
|
|
472
|
+
const user = await userRepo._findOne(users, { id }); // Error
|
|
473
|
+
|
|
474
|
+
// 2. Don't use db for read operations
|
|
475
|
+
async findAll()
|
|
476
|
+
{
|
|
477
|
+
return this.db.select().from(users); // Bad - use readDb
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// 3. Don't put repository logic in routes
|
|
481
|
+
route.get('/users/:id')
|
|
482
|
+
.handler(async (c) => {
|
|
483
|
+
// Bad - business logic in route
|
|
484
|
+
const user = await findOne(users, { id });
|
|
485
|
+
if (!user.isActive) throw new Error('Inactive');
|
|
486
|
+
return user;
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
// 4. Don't bypass transactions in write methods
|
|
490
|
+
async create(data: NewUser)
|
|
491
|
+
{
|
|
492
|
+
// Transactions are handled by route middleware
|
|
493
|
+
// Don't create your own transactions here
|
|
494
|
+
return this._create(users, data);
|
|
495
|
+
}
|
|
496
|
+
```
|