@geekmidas/db 0.0.4 → 0.2.0
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 +495 -0
- package/dist/kysely/pagination.cjs +83 -0
- package/dist/kysely/pagination.cjs.map +1 -0
- package/dist/kysely/pagination.d.cts +78 -0
- package/dist/kysely/pagination.d.mts +78 -0
- package/dist/kysely/pagination.mjs +79 -0
- package/dist/kysely/pagination.mjs.map +1 -0
- package/dist/kysely-0FOi6ZdO.d.cts +11 -0
- package/dist/kysely-8WPSKCZG.cjs +17 -0
- package/dist/kysely-8WPSKCZG.cjs.map +1 -0
- package/dist/kysely-Di1LVvL2.d.mts +11 -0
- package/dist/kysely-DmfA94RY.mjs +11 -0
- package/dist/kysely-DmfA94RY.mjs.map +1 -0
- package/dist/kysely.cjs +2 -8
- package/dist/kysely.d.cts +2 -7
- package/dist/kysely.d.mts +2 -7
- package/dist/kysely.mjs +1 -6
- package/dist/rls.cjs +72 -0
- package/dist/rls.cjs.map +1 -0
- package/dist/rls.d.cts +57 -0
- package/dist/rls.d.mts +57 -0
- package/dist/rls.mjs +48 -0
- package/dist/rls.mjs.map +1 -0
- package/package.json +18 -1
- package/src/__tests__/kysely.integration.spec.ts +741 -0
- package/src/__tests__/kysely.spec.ts +394 -0
- package/src/__tests__/rls.spec.ts +444 -0
- package/src/kysely/__tests__/pagination.integration.spec.ts +341 -0
- package/src/kysely/pagination.ts +150 -0
- package/src/kysely.ts +18 -2
- package/src/rls.ts +90 -0
package/README.md
ADDED
|
@@ -0,0 +1,495 @@
|
|
|
1
|
+
# @geekmidas/db
|
|
2
|
+
|
|
3
|
+
Database utilities for Kysely with flexible transaction management. Provides helpers for working with database connections and transactions in a type-safe way.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- ✅ **Flexible Transaction Handling**: Works with Kysely, Transaction, and ControlledTransaction
|
|
8
|
+
- ✅ **Automatic Transaction Detection**: Reuses existing transactions when nested
|
|
9
|
+
- ✅ **Type-Safe**: Full TypeScript support with generic database schemas
|
|
10
|
+
- ✅ **Connection Abstraction**: Single helper for all database connection types
|
|
11
|
+
- ✅ **Zero Dependencies**: Only peer dependency on Kysely
|
|
12
|
+
|
|
13
|
+
## Installation
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pnpm add @geekmidas/db
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
### Peer Dependencies
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pnpm add kysely pg
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Quick Start
|
|
26
|
+
|
|
27
|
+
```typescript
|
|
28
|
+
import { withTransaction } from '@geekmidas/db/kysely';
|
|
29
|
+
import type { DatabaseConnection } from '@geekmidas/db/kysely';
|
|
30
|
+
import { Kysely } from 'kysely';
|
|
31
|
+
|
|
32
|
+
interface Database {
|
|
33
|
+
users: {
|
|
34
|
+
id: string;
|
|
35
|
+
email: string;
|
|
36
|
+
name: string;
|
|
37
|
+
};
|
|
38
|
+
posts: {
|
|
39
|
+
id: string;
|
|
40
|
+
userId: string;
|
|
41
|
+
title: string;
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function createUserWithPost(
|
|
46
|
+
db: DatabaseConnection<Database>,
|
|
47
|
+
userData: { email: string; name: string },
|
|
48
|
+
postData: { title: string }
|
|
49
|
+
) {
|
|
50
|
+
return withTransaction(db, async (trx) => {
|
|
51
|
+
// Create user
|
|
52
|
+
const user = await trx
|
|
53
|
+
.insertInto('users')
|
|
54
|
+
.values(userData)
|
|
55
|
+
.returningAll()
|
|
56
|
+
.executeTakeFirstOrThrow();
|
|
57
|
+
|
|
58
|
+
// Create post for user
|
|
59
|
+
const post = await trx
|
|
60
|
+
.insertInto('posts')
|
|
61
|
+
.values({
|
|
62
|
+
userId: user.id,
|
|
63
|
+
title: postData.title
|
|
64
|
+
})
|
|
65
|
+
.returningAll()
|
|
66
|
+
.executeTakeFirstOrThrow();
|
|
67
|
+
|
|
68
|
+
return { user, post };
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## API Reference
|
|
74
|
+
|
|
75
|
+
### `withTransaction`
|
|
76
|
+
|
|
77
|
+
Execute a callback within a transaction. If the connection is already a transaction, it reuses it. Otherwise, it creates a new transaction.
|
|
78
|
+
|
|
79
|
+
```typescript
|
|
80
|
+
function withTransaction<DB, T>(
|
|
81
|
+
db: DatabaseConnection<DB>,
|
|
82
|
+
cb: (trx: Transaction<DB>) => Promise<T>
|
|
83
|
+
): Promise<T>
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
**Parameters:**
|
|
87
|
+
- `db` - A database connection (Kysely, Transaction, or ControlledTransaction)
|
|
88
|
+
- `cb` - Callback function that receives the transaction
|
|
89
|
+
|
|
90
|
+
**Returns:**
|
|
91
|
+
- Promise resolving to the callback's return value
|
|
92
|
+
|
|
93
|
+
### `DatabaseConnection<T>`
|
|
94
|
+
|
|
95
|
+
Type union for all supported database connection types:
|
|
96
|
+
|
|
97
|
+
```typescript
|
|
98
|
+
type DatabaseConnection<T> =
|
|
99
|
+
| Kysely<T>
|
|
100
|
+
| Transaction<T>
|
|
101
|
+
| ControlledTransaction<T>;
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## Usage Examples
|
|
105
|
+
|
|
106
|
+
### Basic Transaction
|
|
107
|
+
|
|
108
|
+
```typescript
|
|
109
|
+
import { withTransaction } from '@geekmidas/db/kysely';
|
|
110
|
+
import { Kysely } from 'kysely';
|
|
111
|
+
|
|
112
|
+
const db = new Kysely<Database>({ /* config */ });
|
|
113
|
+
|
|
114
|
+
async function transferFunds(fromId: string, toId: string, amount: number) {
|
|
115
|
+
return withTransaction(db, async (trx) => {
|
|
116
|
+
// Deduct from sender
|
|
117
|
+
await trx
|
|
118
|
+
.updateTable('accounts')
|
|
119
|
+
.set({ balance: sql`balance - ${amount}` })
|
|
120
|
+
.where('id', '=', fromId)
|
|
121
|
+
.execute();
|
|
122
|
+
|
|
123
|
+
// Add to receiver
|
|
124
|
+
await trx
|
|
125
|
+
.updateTable('accounts')
|
|
126
|
+
.set({ balance: sql`balance + ${amount}` })
|
|
127
|
+
.where('id', '=', toId)
|
|
128
|
+
.execute();
|
|
129
|
+
|
|
130
|
+
return { success: true };
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
### Nested Transactions
|
|
136
|
+
|
|
137
|
+
The helper automatically detects existing transactions and reuses them:
|
|
138
|
+
|
|
139
|
+
```typescript
|
|
140
|
+
async function createUser(
|
|
141
|
+
db: DatabaseConnection<Database>,
|
|
142
|
+
email: string
|
|
143
|
+
) {
|
|
144
|
+
return withTransaction(db, async (trx) => {
|
|
145
|
+
const user = await trx
|
|
146
|
+
.insertInto('users')
|
|
147
|
+
.values({ email })
|
|
148
|
+
.returningAll()
|
|
149
|
+
.executeTakeFirstOrThrow();
|
|
150
|
+
|
|
151
|
+
// This will reuse the same transaction
|
|
152
|
+
await createAuditLog(trx, 'user_created', user.id);
|
|
153
|
+
|
|
154
|
+
return user;
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function createAuditLog(
|
|
159
|
+
db: DatabaseConnection<Database>,
|
|
160
|
+
action: string,
|
|
161
|
+
userId: string
|
|
162
|
+
) {
|
|
163
|
+
// If db is already a transaction, it's reused
|
|
164
|
+
return withTransaction(db, async (trx) => {
|
|
165
|
+
await trx
|
|
166
|
+
.insertInto('audit_logs')
|
|
167
|
+
.values({ action, userId, timestamp: new Date() })
|
|
168
|
+
.execute();
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
### Repository Pattern
|
|
174
|
+
|
|
175
|
+
Use `DatabaseConnection` type in repositories for flexibility:
|
|
176
|
+
|
|
177
|
+
```typescript
|
|
178
|
+
import type { DatabaseConnection } from '@geekmidas/db/kysely';
|
|
179
|
+
import { withTransaction } from '@geekmidas/db/kysely';
|
|
180
|
+
|
|
181
|
+
class UserRepository {
|
|
182
|
+
constructor(private db: DatabaseConnection<Database>) {}
|
|
183
|
+
|
|
184
|
+
async create(data: NewUser): Promise<User> {
|
|
185
|
+
return withTransaction(this.db, async (trx) => {
|
|
186
|
+
return trx
|
|
187
|
+
.insertInto('users')
|
|
188
|
+
.values(data)
|
|
189
|
+
.returningAll()
|
|
190
|
+
.executeTakeFirstOrThrow();
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async update(id: string, data: UserUpdate): Promise<User> {
|
|
195
|
+
return withTransaction(this.db, async (trx) => {
|
|
196
|
+
return trx
|
|
197
|
+
.updateTable('users')
|
|
198
|
+
.set(data)
|
|
199
|
+
.where('id', '=', id)
|
|
200
|
+
.returningAll()
|
|
201
|
+
.executeTakeFirstOrThrow();
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Can be used with any connection type
|
|
207
|
+
const db = new Kysely<Database>({ /* config */ });
|
|
208
|
+
const repo = new UserRepository(db);
|
|
209
|
+
|
|
210
|
+
// Or within a transaction
|
|
211
|
+
await withTransaction(db, async (trx) => {
|
|
212
|
+
const repo = new UserRepository(trx);
|
|
213
|
+
await repo.create({ email: 'user@example.com' });
|
|
214
|
+
});
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
### Service Pattern with Transactions
|
|
218
|
+
|
|
219
|
+
```typescript
|
|
220
|
+
import type { DatabaseConnection } from '@geekmidas/db/kysely';
|
|
221
|
+
import { withTransaction } from '@geekmidas/db/kysely';
|
|
222
|
+
|
|
223
|
+
class OrderService {
|
|
224
|
+
constructor(
|
|
225
|
+
private db: DatabaseConnection<Database>,
|
|
226
|
+
private inventoryService: InventoryService,
|
|
227
|
+
private paymentService: PaymentService
|
|
228
|
+
) {}
|
|
229
|
+
|
|
230
|
+
async createOrder(
|
|
231
|
+
userId: string,
|
|
232
|
+
items: OrderItem[]
|
|
233
|
+
): Promise<Order> {
|
|
234
|
+
return withTransaction(this.db, async (trx) => {
|
|
235
|
+
// All operations share the same transaction
|
|
236
|
+
const order = await trx
|
|
237
|
+
.insertInto('orders')
|
|
238
|
+
.values({ userId, status: 'pending' })
|
|
239
|
+
.returningAll()
|
|
240
|
+
.executeTakeFirstOrThrow();
|
|
241
|
+
|
|
242
|
+
// These services can accept the transaction
|
|
243
|
+
await this.inventoryService.reserveItems(trx, items);
|
|
244
|
+
await this.paymentService.processPayment(trx, order.id);
|
|
245
|
+
|
|
246
|
+
// Update order status
|
|
247
|
+
return trx
|
|
248
|
+
.updateTable('orders')
|
|
249
|
+
.set({ status: 'completed' })
|
|
250
|
+
.where('id', '=', order.id)
|
|
251
|
+
.returningAll()
|
|
252
|
+
.executeTakeFirstOrThrow();
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
class InventoryService {
|
|
258
|
+
async reserveItems(
|
|
259
|
+
db: DatabaseConnection<Database>,
|
|
260
|
+
items: OrderItem[]
|
|
261
|
+
) {
|
|
262
|
+
return withTransaction(db, async (trx) => {
|
|
263
|
+
for (const item of items) {
|
|
264
|
+
await trx
|
|
265
|
+
.updateTable('inventory')
|
|
266
|
+
.set({ reserved: sql`reserved + ${item.quantity}` })
|
|
267
|
+
.where('productId', '=', item.productId)
|
|
268
|
+
.execute();
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
### Error Handling
|
|
276
|
+
|
|
277
|
+
Transactions automatically roll back on errors:
|
|
278
|
+
|
|
279
|
+
```typescript
|
|
280
|
+
import { withTransaction } from '@geekmidas/db/kysely';
|
|
281
|
+
|
|
282
|
+
async function processOrder(db: DatabaseConnection<Database>, orderId: string) {
|
|
283
|
+
try {
|
|
284
|
+
return await withTransaction(db, async (trx) => {
|
|
285
|
+
const order = await trx
|
|
286
|
+
.selectFrom('orders')
|
|
287
|
+
.where('id', '=', orderId)
|
|
288
|
+
.selectAll()
|
|
289
|
+
.executeTakeFirstOrThrow();
|
|
290
|
+
|
|
291
|
+
if (order.status !== 'pending') {
|
|
292
|
+
throw new Error('Order already processed');
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Update order
|
|
296
|
+
await trx
|
|
297
|
+
.updateTable('orders')
|
|
298
|
+
.set({ status: 'processing' })
|
|
299
|
+
.where('id', '=', orderId)
|
|
300
|
+
.execute();
|
|
301
|
+
|
|
302
|
+
// If this throws, the entire transaction rolls back
|
|
303
|
+
await processPayment(trx, orderId);
|
|
304
|
+
|
|
305
|
+
return order;
|
|
306
|
+
});
|
|
307
|
+
} catch (error) {
|
|
308
|
+
console.error('Transaction failed:', error);
|
|
309
|
+
// Transaction has been rolled back
|
|
310
|
+
throw error;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
### Testing with Transactions
|
|
316
|
+
|
|
317
|
+
Use transactions for test isolation:
|
|
318
|
+
|
|
319
|
+
```typescript
|
|
320
|
+
import { describe, it, beforeEach, afterEach } from 'vitest';
|
|
321
|
+
import type { Transaction } from 'kysely';
|
|
322
|
+
|
|
323
|
+
describe('UserRepository', () => {
|
|
324
|
+
let trx: Transaction<Database>;
|
|
325
|
+
|
|
326
|
+
beforeEach(async () => {
|
|
327
|
+
trx = await db.transaction().execute(async (t) => t);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
afterEach(async () => {
|
|
331
|
+
await trx.rollback();
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it('should create user', async () => {
|
|
335
|
+
const repo = new UserRepository(trx);
|
|
336
|
+
const user = await repo.create({ email: 'test@example.com' });
|
|
337
|
+
|
|
338
|
+
expect(user.email).toBe('test@example.com');
|
|
339
|
+
// Transaction will be rolled back after test
|
|
340
|
+
});
|
|
341
|
+
});
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
## Type Safety
|
|
345
|
+
|
|
346
|
+
The package provides full type safety for database operations:
|
|
347
|
+
|
|
348
|
+
```typescript
|
|
349
|
+
import type { DatabaseConnection } from '@geekmidas/db/kysely';
|
|
350
|
+
import { withTransaction } from '@geekmidas/db/kysely';
|
|
351
|
+
|
|
352
|
+
interface Database {
|
|
353
|
+
users: {
|
|
354
|
+
id: Generated<string>;
|
|
355
|
+
email: string;
|
|
356
|
+
name: string | null;
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function updateUser(
|
|
361
|
+
db: DatabaseConnection<Database>,
|
|
362
|
+
id: string,
|
|
363
|
+
data: { name: string }
|
|
364
|
+
) {
|
|
365
|
+
return withTransaction(db, async (trx) => {
|
|
366
|
+
// Full autocomplete and type checking
|
|
367
|
+
return trx
|
|
368
|
+
.updateTable('users')
|
|
369
|
+
.set(data) // Type-checked against users table
|
|
370
|
+
.where('id', '=', id)
|
|
371
|
+
.returningAll()
|
|
372
|
+
.executeTakeFirstOrThrow();
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
## Advanced Patterns
|
|
378
|
+
|
|
379
|
+
### Unit of Work Pattern
|
|
380
|
+
|
|
381
|
+
```typescript
|
|
382
|
+
class UnitOfWork {
|
|
383
|
+
private transaction: Transaction<Database> | null = null;
|
|
384
|
+
|
|
385
|
+
constructor(private db: Kysely<Database>) {}
|
|
386
|
+
|
|
387
|
+
async begin() {
|
|
388
|
+
this.transaction = await this.db.transaction().execute(async (t) => t);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
async commit() {
|
|
392
|
+
if (this.transaction) {
|
|
393
|
+
await this.transaction.commit();
|
|
394
|
+
this.transaction = null;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
async rollback() {
|
|
399
|
+
if (this.transaction) {
|
|
400
|
+
await this.transaction.rollback();
|
|
401
|
+
this.transaction = null;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
getConnection(): DatabaseConnection<Database> {
|
|
406
|
+
return this.transaction || this.db;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// Usage
|
|
411
|
+
const uow = new UnitOfWork(db);
|
|
412
|
+
await uow.begin();
|
|
413
|
+
|
|
414
|
+
try {
|
|
415
|
+
const userRepo = new UserRepository(uow.getConnection());
|
|
416
|
+
const orderRepo = new OrderRepository(uow.getConnection());
|
|
417
|
+
|
|
418
|
+
await userRepo.create({ email: 'user@example.com' });
|
|
419
|
+
await orderRepo.create({ userId: '123' });
|
|
420
|
+
|
|
421
|
+
await uow.commit();
|
|
422
|
+
} catch (error) {
|
|
423
|
+
await uow.rollback();
|
|
424
|
+
throw error;
|
|
425
|
+
}
|
|
426
|
+
```
|
|
427
|
+
|
|
428
|
+
### Connection Pooling
|
|
429
|
+
|
|
430
|
+
```typescript
|
|
431
|
+
import { Pool } from 'pg';
|
|
432
|
+
import { Kysely, PostgresDialect } from 'kysely';
|
|
433
|
+
|
|
434
|
+
const pool = new Pool({
|
|
435
|
+
host: 'localhost',
|
|
436
|
+
database: 'mydb',
|
|
437
|
+
max: 10
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
const db = new Kysely<Database>({
|
|
441
|
+
dialect: new PostgresDialect({ pool })
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
// Use with withTransaction
|
|
445
|
+
async function processData(data: unknown[]) {
|
|
446
|
+
return withTransaction(db, async (trx) => {
|
|
447
|
+
// Each transaction gets a connection from the pool
|
|
448
|
+
for (const item of data) {
|
|
449
|
+
await trx.insertInto('items').values(item).execute();
|
|
450
|
+
}
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
```
|
|
454
|
+
|
|
455
|
+
## Best Practices
|
|
456
|
+
|
|
457
|
+
1. **Use DatabaseConnection Type**: Accept `DatabaseConnection` in functions that work with transactions
|
|
458
|
+
```typescript
|
|
459
|
+
async function myFunction(db: DatabaseConnection<Database>) { }
|
|
460
|
+
```
|
|
461
|
+
|
|
462
|
+
2. **Let withTransaction Handle Reuse**: Don't manually check for transaction type
|
|
463
|
+
```typescript
|
|
464
|
+
// Good
|
|
465
|
+
await withTransaction(db, async (trx) => { });
|
|
466
|
+
|
|
467
|
+
// Avoid
|
|
468
|
+
if (db.isTransaction) { /* ... */ } else { /* ... */ }
|
|
469
|
+
```
|
|
470
|
+
|
|
471
|
+
3. **Keep Transactions Short**: Execute quickly to avoid blocking
|
|
472
|
+
```typescript
|
|
473
|
+
// Good
|
|
474
|
+
await withTransaction(db, async (trx) => {
|
|
475
|
+
await trx.insertInto('users').values(data).execute();
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
// Avoid long-running operations
|
|
479
|
+
await withTransaction(db, async (trx) => {
|
|
480
|
+
await fetch('https://api.example.com'); // Bad!
|
|
481
|
+
});
|
|
482
|
+
```
|
|
483
|
+
|
|
484
|
+
4. **Error Handling**: Let transactions roll back automatically on errors
|
|
485
|
+
|
|
486
|
+
5. **Testing**: Use transactions for test isolation with automatic rollback
|
|
487
|
+
|
|
488
|
+
## Related Packages
|
|
489
|
+
|
|
490
|
+
- [Kysely](https://github.com/kysely-org/kysely) - Type-safe SQL query builder
|
|
491
|
+
- [@geekmidas/testkit](../testkit) - Testing utilities with database factories
|
|
492
|
+
|
|
493
|
+
## License
|
|
494
|
+
|
|
495
|
+
MIT
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
|
|
2
|
+
//#region src/kysely/pagination.ts
|
|
3
|
+
/**
|
|
4
|
+
* Sort direction for cursor-based pagination.
|
|
5
|
+
*/
|
|
6
|
+
let Direction = /* @__PURE__ */ function(Direction$1) {
|
|
7
|
+
Direction$1["Asc"] = "asc";
|
|
8
|
+
Direction$1["Desc"] = "desc";
|
|
9
|
+
return Direction$1;
|
|
10
|
+
}({});
|
|
11
|
+
/**
|
|
12
|
+
* Generic paginated search function that handles:
|
|
13
|
+
* - Total count calculation
|
|
14
|
+
* - Cursor-based pagination
|
|
15
|
+
* - Fetching one extra row to determine hasMore
|
|
16
|
+
* - Mapping rows to output format
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* ```typescript
|
|
20
|
+
* const result = await paginatedSearch({
|
|
21
|
+
* query: db.selectFrom('users').selectAll(),
|
|
22
|
+
* cursor: previousCursor,
|
|
23
|
+
* limit: 20,
|
|
24
|
+
* mapRow: (row) => ({ id: row.id, name: row.name }),
|
|
25
|
+
* cursorField: 'id',
|
|
26
|
+
* cursorDirection: Direction.Asc,
|
|
27
|
+
* });
|
|
28
|
+
* ```
|
|
29
|
+
*/
|
|
30
|
+
async function paginatedSearch({ query, cursor, limit = 20, mapRow, cursorField = "id", cursorDirection = Direction.Asc }) {
|
|
31
|
+
const countResult = await query.clearSelect().clearOrderBy().select((eb) => eb.fn.countAll().as("count")).executeTakeFirstOrThrow();
|
|
32
|
+
const count = countResult.count;
|
|
33
|
+
let paginatedQuery = query;
|
|
34
|
+
if (cursor) {
|
|
35
|
+
const operator = cursorDirection === Direction.Asc ? ">" : "<";
|
|
36
|
+
paginatedQuery = paginatedQuery.where(cursorField, operator, cursor);
|
|
37
|
+
}
|
|
38
|
+
const data = await paginatedQuery.orderBy(cursorField, cursorDirection).limit(limit + 1).execute();
|
|
39
|
+
const hasMore = data.length > limit;
|
|
40
|
+
const rows = hasMore ? data.slice(0, limit) : data;
|
|
41
|
+
const lastRow = rows[rows.length - 1];
|
|
42
|
+
const nextCursor = hasMore && lastRow ? String(lastRow[cursorField]) : void 0;
|
|
43
|
+
const items = await Promise.all(rows.map(mapRow));
|
|
44
|
+
return {
|
|
45
|
+
items,
|
|
46
|
+
pagination: {
|
|
47
|
+
total: Number(count),
|
|
48
|
+
hasMore,
|
|
49
|
+
cursor: nextCursor
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Encode a cursor value for safe URL transmission.
|
|
55
|
+
* Supports various types: string, number, Date, etc.
|
|
56
|
+
*/
|
|
57
|
+
function encodeCursor(value) {
|
|
58
|
+
const payload = {
|
|
59
|
+
v: value instanceof Date ? value.toISOString() : value,
|
|
60
|
+
t: value instanceof Date ? "date" : typeof value
|
|
61
|
+
};
|
|
62
|
+
return Buffer.from(JSON.stringify(payload)).toString("base64url");
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Decode a cursor string back to its original value.
|
|
66
|
+
*/
|
|
67
|
+
function decodeCursor(cursor) {
|
|
68
|
+
try {
|
|
69
|
+
const json = Buffer.from(cursor, "base64url").toString("utf-8");
|
|
70
|
+
const payload = JSON.parse(json);
|
|
71
|
+
if (payload.t === "date") return new Date(payload.v);
|
|
72
|
+
return payload.v;
|
|
73
|
+
} catch {
|
|
74
|
+
throw new Error("Invalid cursor format");
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
//#endregion
|
|
79
|
+
exports.Direction = Direction;
|
|
80
|
+
exports.decodeCursor = decodeCursor;
|
|
81
|
+
exports.encodeCursor = encodeCursor;
|
|
82
|
+
exports.paginatedSearch = paginatedSearch;
|
|
83
|
+
//# sourceMappingURL=pagination.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"pagination.cjs","names":["value: unknown","cursor: string"],"sources":["../../src/kysely/pagination.ts"],"sourcesContent":["import type { SelectQueryBuilder } from 'kysely';\n\n/**\n * Sort direction for cursor-based pagination.\n */\nexport enum Direction {\n Asc = 'asc',\n Desc = 'desc',\n}\n\n/**\n * Result of a paginated query.\n */\nexport interface PaginationResult<TItem> {\n items: TItem[];\n pagination: {\n total: number;\n hasMore: boolean;\n cursor?: string;\n };\n}\n\n/**\n * Options for paginated search.\n */\nexport interface PaginatedSearchOptions<\n TRow,\n TMapRow extends (row: TRow) => unknown,\n> {\n /** The base Kysely query to paginate */\n query: SelectQueryBuilder<any, any, TRow>;\n /** Cursor value for pagination (value of cursorField from previous page) */\n cursor?: string;\n /** Maximum number of items per page (default: 20) */\n limit?: number;\n /** Function to transform each row to the output format */\n mapRow: TMapRow;\n /** Field to use for cursor pagination (default: 'id') */\n cursorField?: string;\n /** Sort direction for cursor field (default: Direction.Asc) */\n cursorDirection?: Direction;\n}\n\n/**\n * Generic paginated search function that handles:\n * - Total count calculation\n * - Cursor-based pagination\n * - Fetching one extra row to determine hasMore\n * - Mapping rows to output format\n *\n * @example\n * ```typescript\n * const result = await paginatedSearch({\n * query: db.selectFrom('users').selectAll(),\n * cursor: previousCursor,\n * limit: 20,\n * mapRow: (row) => ({ id: row.id, name: row.name }),\n * cursorField: 'id',\n * cursorDirection: Direction.Asc,\n * });\n * ```\n */\nexport async function paginatedSearch<\n TRow extends Record<string, unknown>,\n TMapRow extends (row: TRow) => unknown,\n>({\n query,\n cursor,\n limit = 20,\n mapRow,\n cursorField = 'id',\n cursorDirection = Direction.Asc,\n}: PaginatedSearchOptions<TRow, TMapRow>): Promise<\n PaginationResult<Awaited<ReturnType<TMapRow>>>\n> {\n // Get total count (without cursor)\n const countResult = await query\n .clearSelect()\n .clearOrderBy()\n .select((eb) => eb.fn.countAll().as('count'))\n .executeTakeFirstOrThrow();\n\n const count = countResult.count;\n\n // Apply cursor if provided\n let paginatedQuery = query;\n if (cursor) {\n const operator = cursorDirection === Direction.Asc ? '>' : '<';\n paginatedQuery = paginatedQuery.where(\n cursorField as any,\n operator,\n cursor,\n ) as typeof query;\n }\n\n // Fetch one extra to determine if there are more results\n const data = await paginatedQuery\n .orderBy(cursorField as any, cursorDirection)\n .limit(limit + 1)\n .execute();\n\n const hasMore = data.length > limit;\n const rows = hasMore ? data.slice(0, limit) : data;\n const lastRow = rows[rows.length - 1];\n const nextCursor =\n hasMore && lastRow ? String(lastRow[cursorField]) : undefined;\n\n const items = (await Promise.all(rows.map(mapRow))) as Awaited<\n ReturnType<TMapRow>\n >[];\n\n return {\n items,\n pagination: {\n total: Number(count),\n hasMore,\n cursor: nextCursor,\n },\n };\n}\n\n/**\n * Encode a cursor value for safe URL transmission.\n * Supports various types: string, number, Date, etc.\n */\nexport function encodeCursor(value: unknown): string {\n const payload = {\n v: value instanceof Date ? value.toISOString() : value,\n t: value instanceof Date ? 'date' : typeof value,\n };\n return Buffer.from(JSON.stringify(payload)).toString('base64url');\n}\n\n/**\n * Decode a cursor string back to its original value.\n */\nexport function decodeCursor(cursor: string): unknown {\n try {\n const json = Buffer.from(cursor, 'base64url').toString('utf-8');\n const payload = JSON.parse(json);\n\n if (payload.t === 'date') {\n return new Date(payload.v);\n }\n\n return payload.v;\n } catch {\n throw new Error('Invalid cursor format');\n }\n}\n"],"mappings":";;;;;AAKA,IAAY,kDAAL;AACL;AACA;;AACD;;;;;;;;;;;;;;;;;;;;AAsDD,eAAsB,gBAGpB,EACA,OACA,QACA,QAAQ,IACR,QACA,cAAc,MACd,kBAAkB,UAAU,KACU,EAEtC;CAEA,MAAM,cAAc,MAAM,MACvB,aAAa,CACb,cAAc,CACd,OAAO,CAAC,OAAO,GAAG,GAAG,UAAU,CAAC,GAAG,QAAQ,CAAC,CAC5C,yBAAyB;CAE5B,MAAM,QAAQ,YAAY;CAG1B,IAAI,iBAAiB;AACrB,KAAI,QAAQ;EACV,MAAM,WAAW,oBAAoB,UAAU,MAAM,MAAM;AAC3D,mBAAiB,eAAe,MAC9B,aACA,UACA,OACD;CACF;CAGD,MAAM,OAAO,MAAM,eAChB,QAAQ,aAAoB,gBAAgB,CAC5C,MAAM,QAAQ,EAAE,CAChB,SAAS;CAEZ,MAAM,UAAU,KAAK,SAAS;CAC9B,MAAM,OAAO,UAAU,KAAK,MAAM,GAAG,MAAM,GAAG;CAC9C,MAAM,UAAU,KAAK,KAAK,SAAS;CACnC,MAAM,aACJ,WAAW,UAAU,OAAO,QAAQ,aAAa;CAEnD,MAAM,QAAS,MAAM,QAAQ,IAAI,KAAK,IAAI,OAAO,CAAC;AAIlD,QAAO;EACL;EACA,YAAY;GACV,OAAO,OAAO,MAAM;GACpB;GACA,QAAQ;EACT;CACF;AACF;;;;;AAMD,SAAgB,aAAaA,OAAwB;CACnD,MAAM,UAAU;EACd,GAAG,iBAAiB,OAAO,MAAM,aAAa,GAAG;EACjD,GAAG,iBAAiB,OAAO,gBAAgB;CAC5C;AACD,QAAO,OAAO,KAAK,KAAK,UAAU,QAAQ,CAAC,CAAC,SAAS,YAAY;AAClE;;;;AAKD,SAAgB,aAAaC,QAAyB;AACpD,KAAI;EACF,MAAM,OAAO,OAAO,KAAK,QAAQ,YAAY,CAAC,SAAS,QAAQ;EAC/D,MAAM,UAAU,KAAK,MAAM,KAAK;AAEhC,MAAI,QAAQ,MAAM,OAChB,QAAO,IAAI,KAAK,QAAQ;AAG1B,SAAO,QAAQ;CAChB,QAAO;AACN,QAAM,IAAI,MAAM;CACjB;AACF"}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { SelectQueryBuilder } from "kysely";
|
|
2
|
+
|
|
3
|
+
//#region src/kysely/pagination.d.ts
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Sort direction for cursor-based pagination.
|
|
7
|
+
*/
|
|
8
|
+
declare enum Direction {
|
|
9
|
+
Asc = "asc",
|
|
10
|
+
Desc = "desc",
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Result of a paginated query.
|
|
14
|
+
*/
|
|
15
|
+
interface PaginationResult<TItem> {
|
|
16
|
+
items: TItem[];
|
|
17
|
+
pagination: {
|
|
18
|
+
total: number;
|
|
19
|
+
hasMore: boolean;
|
|
20
|
+
cursor?: string;
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Options for paginated search.
|
|
25
|
+
*/
|
|
26
|
+
interface PaginatedSearchOptions<TRow, TMapRow extends (row: TRow) => unknown> {
|
|
27
|
+
/** The base Kysely query to paginate */
|
|
28
|
+
query: SelectQueryBuilder<any, any, TRow>;
|
|
29
|
+
/** Cursor value for pagination (value of cursorField from previous page) */
|
|
30
|
+
cursor?: string;
|
|
31
|
+
/** Maximum number of items per page (default: 20) */
|
|
32
|
+
limit?: number;
|
|
33
|
+
/** Function to transform each row to the output format */
|
|
34
|
+
mapRow: TMapRow;
|
|
35
|
+
/** Field to use for cursor pagination (default: 'id') */
|
|
36
|
+
cursorField?: string;
|
|
37
|
+
/** Sort direction for cursor field (default: Direction.Asc) */
|
|
38
|
+
cursorDirection?: Direction;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Generic paginated search function that handles:
|
|
42
|
+
* - Total count calculation
|
|
43
|
+
* - Cursor-based pagination
|
|
44
|
+
* - Fetching one extra row to determine hasMore
|
|
45
|
+
* - Mapping rows to output format
|
|
46
|
+
*
|
|
47
|
+
* @example
|
|
48
|
+
* ```typescript
|
|
49
|
+
* const result = await paginatedSearch({
|
|
50
|
+
* query: db.selectFrom('users').selectAll(),
|
|
51
|
+
* cursor: previousCursor,
|
|
52
|
+
* limit: 20,
|
|
53
|
+
* mapRow: (row) => ({ id: row.id, name: row.name }),
|
|
54
|
+
* cursorField: 'id',
|
|
55
|
+
* cursorDirection: Direction.Asc,
|
|
56
|
+
* });
|
|
57
|
+
* ```
|
|
58
|
+
*/
|
|
59
|
+
declare function paginatedSearch<TRow extends Record<string, unknown>, TMapRow extends (row: TRow) => unknown>({
|
|
60
|
+
query,
|
|
61
|
+
cursor,
|
|
62
|
+
limit,
|
|
63
|
+
mapRow,
|
|
64
|
+
cursorField,
|
|
65
|
+
cursorDirection
|
|
66
|
+
}: PaginatedSearchOptions<TRow, TMapRow>): Promise<PaginationResult<Awaited<ReturnType<TMapRow>>>>;
|
|
67
|
+
/**
|
|
68
|
+
* Encode a cursor value for safe URL transmission.
|
|
69
|
+
* Supports various types: string, number, Date, etc.
|
|
70
|
+
*/
|
|
71
|
+
declare function encodeCursor(value: unknown): string;
|
|
72
|
+
/**
|
|
73
|
+
* Decode a cursor string back to its original value.
|
|
74
|
+
*/
|
|
75
|
+
declare function decodeCursor(cursor: string): unknown;
|
|
76
|
+
//#endregion
|
|
77
|
+
export { Direction, PaginatedSearchOptions, PaginationResult, decodeCursor, encodeCursor, paginatedSearch };
|
|
78
|
+
//# sourceMappingURL=pagination.d.cts.map
|