@spfn/core 0.2.0-beta.2 → 0.2.0-beta.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +262 -1092
- package/dist/{boss-D-fGtVgM.d.ts → boss-DI1r4kTS.d.ts} +68 -11
- package/dist/codegen/index.d.ts +55 -8
- package/dist/codegen/index.js +179 -5
- package/dist/codegen/index.js.map +1 -1
- package/dist/config/index.d.ts +204 -6
- package/dist/config/index.js +44 -11
- package/dist/config/index.js.map +1 -1
- package/dist/db/index.d.ts +13 -0
- package/dist/db/index.js +92 -33
- package/dist/db/index.js.map +1 -1
- package/dist/env/index.d.ts +83 -3
- package/dist/env/index.js +83 -15
- package/dist/env/index.js.map +1 -1
- package/dist/env/loader.d.ts +95 -0
- package/dist/env/loader.js +78 -0
- package/dist/env/loader.js.map +1 -0
- package/dist/event/index.d.ts +29 -70
- package/dist/event/index.js +15 -1
- package/dist/event/index.js.map +1 -1
- package/dist/event/sse/client.d.ts +157 -0
- package/dist/event/sse/client.js +169 -0
- package/dist/event/sse/client.js.map +1 -0
- package/dist/event/sse/index.d.ts +46 -0
- package/dist/event/sse/index.js +205 -0
- package/dist/event/sse/index.js.map +1 -0
- package/dist/job/index.d.ts +54 -8
- package/dist/job/index.js +61 -12
- package/dist/job/index.js.map +1 -1
- package/dist/middleware/index.d.ts +124 -11
- package/dist/middleware/index.js +41 -7
- package/dist/middleware/index.js.map +1 -1
- package/dist/nextjs/index.d.ts +2 -2
- package/dist/nextjs/index.js +37 -5
- package/dist/nextjs/index.js.map +1 -1
- package/dist/nextjs/server.d.ts +45 -24
- package/dist/nextjs/server.js +87 -66
- package/dist/nextjs/server.js.map +1 -1
- package/dist/route/index.d.ts +207 -14
- package/dist/route/index.js +304 -31
- package/dist/route/index.js.map +1 -1
- package/dist/route/types.d.ts +2 -31
- package/dist/router-Di7ENoah.d.ts +151 -0
- package/dist/server/index.d.ts +321 -10
- package/dist/server/index.js +798 -189
- package/dist/server/index.js.map +1 -1
- package/dist/{types-DRG2XMTR.d.ts → types-7Mhoxnnt.d.ts} +97 -4
- package/dist/types-DHQMQlcb.d.ts +305 -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 +499 -0
- package/docs/errors.md +319 -0
- package/docs/event.md +432 -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 +247 -0
- package/docs/repository.md +496 -0
- package/docs/route.md +497 -0
- package/docs/server.md +429 -0
- package/package.json +19 -3
|
@@ -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
|
+
```
|