@spfn/core 0.2.0-beta.6 → 0.2.0-beta.9

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/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
+ ```