@geekmidas/db 0.2.0 → 0.3.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.
@@ -1,9 +1,9 @@
1
1
  import {
2
- CamelCasePlugin,
3
- type Generated,
4
- Kysely,
5
- PostgresDialect,
6
- sql,
2
+ CamelCasePlugin,
3
+ type Generated,
4
+ Kysely,
5
+ PostgresDialect,
6
+ sql,
7
7
  } from 'kysely';
8
8
  import pg from 'pg';
9
9
  import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
@@ -11,731 +11,731 @@ import { TEST_DATABASE_CONFIG } from '../../../testkit/test/globalSetup';
11
11
  import { withTransaction } from '../kysely';
12
12
 
13
13
  interface TestDatabase {
14
- kyselyTrxUsers: {
15
- id: Generated<number>;
16
- name: string;
17
- email: string;
18
- createdAt: Generated<Date>;
19
- };
20
- kyselyTrxAccounts: {
21
- id: Generated<number>;
22
- userId: number;
23
- balance: number;
24
- version: number;
25
- };
14
+ kyselyTrxUsers: {
15
+ id: Generated<number>;
16
+ name: string;
17
+ email: string;
18
+ createdAt: Generated<Date>;
19
+ };
20
+ kyselyTrxAccounts: {
21
+ id: Generated<number>;
22
+ userId: number;
23
+ balance: number;
24
+ version: number;
25
+ };
26
26
  }
27
27
 
28
28
  describe('Kysely Transaction Integration Tests', () => {
29
- let db: Kysely<TestDatabase>;
30
-
31
- beforeAll(async () => {
32
- db = new Kysely<TestDatabase>({
33
- dialect: new PostgresDialect({
34
- pool: new pg.Pool({
35
- ...TEST_DATABASE_CONFIG,
36
- database: 'postgres',
37
- }),
38
- }),
39
- plugins: [new CamelCasePlugin()],
40
- });
41
-
42
- // Create users table
43
- await db.schema
44
- .createTable('kysely_trx_users')
45
- .ifNotExists()
46
- .addColumn('id', 'serial', (col) => col.primaryKey())
47
- .addColumn('name', 'varchar', (col) => col.notNull())
48
- .addColumn('email', 'varchar', (col) => col.notNull().unique())
49
- .addColumn('created_at', 'timestamp', (col) =>
50
- col.defaultTo(sql`now()`).notNull(),
51
- )
52
- .execute();
53
-
54
- // Create accounts table
55
- await db.schema
56
- .createTable('kysely_trx_accounts')
57
- .ifNotExists()
58
- .addColumn('id', 'serial', (col) => col.primaryKey())
59
- .addColumn('user_id', 'integer', (col) =>
60
- col.notNull().references('kysely_trx_users.id').onDelete('cascade'),
61
- )
62
- .addColumn('balance', 'numeric(10, 2)', (col) =>
63
- col.notNull().defaultTo(0),
64
- )
65
- .addColumn('version', 'integer', (col) => col.notNull().defaultTo(0))
66
- .execute();
67
- });
68
-
69
- afterEach(async () => {
70
- // Clean up data after each test
71
- await db.deleteFrom('kyselyTrxAccounts').execute();
72
- await db.deleteFrom('kyselyTrxUsers').execute();
73
- });
74
-
75
- afterAll(async () => {
76
- // Drop tables and close connection
77
- await db.schema.dropTable('kysely_trx_accounts').ifExists().execute();
78
- await db.schema.dropTable('kysely_trx_users').ifExists().execute();
79
- await db.destroy();
80
- });
81
-
82
- describe('withTransaction - Real Database Operations', () => {
83
- it('should execute real insert and select within transaction', async () => {
84
- const result = await withTransaction(db, async (trx) => {
85
- // Insert a user
86
- const user = await trx
87
- .insertInto('kyselyTrxUsers')
88
- .values({
89
- name: 'John Doe',
90
- email: 'john@example.com',
91
- })
92
- .returningAll()
93
- .executeTakeFirstOrThrow();
94
-
95
- // Verify we can select it within the same transaction
96
- const foundUser = await trx
97
- .selectFrom('kyselyTrxUsers')
98
- .selectAll()
99
- .where('id', '=', user.id)
100
- .executeTakeFirstOrThrow();
101
-
102
- expect(foundUser.name).toBe('John Doe');
103
- expect(foundUser.email).toBe('john@example.com');
104
-
105
- return user;
106
- });
107
-
108
- expect(result).toBeDefined();
109
- expect(result.id).toBeDefined();
110
- expect(result.name).toBe('John Doe');
111
- });
112
-
113
- it('should rollback on error', async () => {
114
- const insertPromise = withTransaction(db, async (trx) => {
115
- // Insert a user
116
- await trx
117
- .insertInto('kyselyTrxUsers')
118
- .values({
119
- name: 'Will Be Rolled Back',
120
- email: 'rollback@example.com',
121
- })
122
- .execute();
123
-
124
- // Throw an error
125
- throw new Error('Transaction should rollback');
126
- });
127
-
128
- await expect(insertPromise).rejects.toThrow(
129
- 'Transaction should rollback',
130
- );
131
-
132
- // Verify user was not created
133
- const users = await db
134
- .selectFrom('kyselyTrxUsers')
135
- .selectAll()
136
- .where('email', '=', 'rollback@example.com')
137
- .execute();
138
-
139
- expect(users).toHaveLength(0);
140
- });
141
-
142
- it('should reuse existing transaction', async () => {
143
- await withTransaction(db, async (trx1) => {
144
- // Insert user in outer transaction
145
- const user = await trx1
146
- .insertInto('kyselyTrxUsers')
147
- .values({
148
- name: 'Outer Transaction',
149
- email: 'outer@example.com',
150
- })
151
- .returningAll()
152
- .executeTakeFirstOrThrow();
153
-
154
- // Nested transaction should reuse the same transaction
155
- await withTransaction(trx1, async (trx2) => {
156
- // Should see the user from outer transaction
157
- const foundUser = await trx2
158
- .selectFrom('kyselyTrxUsers')
159
- .selectAll()
160
- .where('id', '=', user.id)
161
- .executeTakeFirstOrThrow();
162
-
163
- expect(foundUser.name).toBe('Outer Transaction');
164
-
165
- // Insert an account
166
- await trx2
167
- .insertInto('kyselyTrxAccounts')
168
- .values({
169
- userId: user.id,
170
- balance: 1000,
171
- version: 0,
172
- })
173
- .execute();
174
- });
175
-
176
- // Verify account was created in the same transaction
177
- const accounts = await trx1
178
- .selectFrom('kyselyTrxAccounts')
179
- .selectAll()
180
- .where('userId', '=', user.id)
181
- .execute();
182
-
183
- expect(accounts).toHaveLength(1);
184
- });
185
- });
186
- });
187
-
188
- describe('SELECT FOR UPDATE', () => {
189
- it('should lock row with SELECT FOR UPDATE', async () => {
190
- // Create a user and account
191
- const user = await db
192
- .insertInto('kyselyTrxUsers')
193
- .values({
194
- name: 'Test User',
195
- email: 'test@example.com',
196
- })
197
- .returningAll()
198
- .executeTakeFirstOrThrow();
199
-
200
- const account = await db
201
- .insertInto('kyselyTrxAccounts')
202
- .values({
203
- userId: user.id,
204
- balance: 1000,
205
- version: 0,
206
- })
207
- .returningAll()
208
- .executeTakeFirstOrThrow();
209
-
210
- // Start two concurrent transactions
211
- const transaction1Promise = withTransaction(db, async (trx1) => {
212
- // Lock the account row
213
- const lockedAccount = await trx1
214
- .selectFrom('kyselyTrxAccounts')
215
- .selectAll()
216
- .where('id', '=', account.id)
217
- .forUpdate()
218
- .executeTakeFirstOrThrow();
219
-
220
- expect(lockedAccount.balance).toBe('1000.00');
221
-
222
- // Simulate some processing time
223
- await new Promise((resolve) => setTimeout(resolve, 100));
224
-
225
- // Update the balance
226
- await trx1
227
- .updateTable('kyselyTrxAccounts')
228
- .set({
229
- balance: sql`balance - 100`,
230
- version: sql`version + 1`,
231
- })
232
- .where('id', '=', account.id)
233
- .execute();
234
-
235
- return 'transaction1-complete';
236
- });
237
-
238
- // Wait a bit to ensure transaction1 has acquired the lock
239
- await new Promise((resolve) => setTimeout(resolve, 10));
240
-
241
- // This transaction should wait for the lock
242
- const transaction2Promise = withTransaction(db, async (trx2) => {
243
- // This will wait for transaction1 to release the lock
244
- const lockedAccount = await trx2
245
- .selectFrom('kyselyTrxAccounts')
246
- .selectAll()
247
- .where('id', '=', account.id)
248
- .forUpdate()
249
- .executeTakeFirstOrThrow();
250
-
251
- // By the time we get here, transaction1 should have completed
252
- expect(Number(lockedAccount.balance)).toBe(900);
253
-
254
- // Update the balance again
255
- await trx2
256
- .updateTable('kyselyTrxAccounts')
257
- .set({
258
- balance: sql`balance - 50`,
259
- version: sql`version + 1`,
260
- })
261
- .where('id', '=', account.id)
262
- .execute();
263
-
264
- return 'transaction2-complete';
265
- });
266
-
267
- // Wait for both transactions to complete
268
- const results = await Promise.all([
269
- transaction1Promise,
270
- transaction2Promise,
271
- ]);
272
-
273
- expect(results).toEqual([
274
- 'transaction1-complete',
275
- 'transaction2-complete',
276
- ]);
277
-
278
- // Verify final balance
279
- const finalAccount = await db
280
- .selectFrom('kyselyTrxAccounts')
281
- .selectAll()
282
- .where('id', '=', account.id)
283
- .executeTakeFirstOrThrow();
284
-
285
- expect(Number(finalAccount.balance)).toBe(850);
286
- expect(finalAccount.version).toBe(2);
287
- });
288
-
289
- it('should prevent concurrent updates with SELECT FOR UPDATE', async () => {
290
- // Create user and account
291
- const user = await db
292
- .insertInto('kyselyTrxUsers')
293
- .values({
294
- name: 'Concurrent User',
295
- email: 'concurrent@example.com',
296
- })
297
- .returningAll()
298
- .executeTakeFirstOrThrow();
299
-
300
- const account = await db
301
- .insertInto('kyselyTrxAccounts')
302
- .values({
303
- userId: user.id,
304
- balance: 500,
305
- version: 0,
306
- })
307
- .returningAll()
308
- .executeTakeFirstOrThrow();
309
-
310
- // Perform multiple concurrent withdrawals
311
- const withdrawals = [100, 150, 200];
312
- const results = await Promise.all(
313
- withdrawals.map((amount) =>
314
- withTransaction(db, async (trx) => {
315
- // Lock the row
316
- const currentAccount = await trx
317
- .selectFrom('kyselyTrxAccounts')
318
- .selectAll()
319
- .where('id', '=', account.id)
320
- .forUpdate()
321
- .executeTakeFirstOrThrow();
322
-
323
- // Check if there's enough balance
324
- if (Number(currentAccount.balance) >= amount) {
325
- await trx
326
- .updateTable('kyselyTrxAccounts')
327
- .set({
328
- balance: sql`balance - ${amount}`,
329
- version: sql`version + 1`,
330
- })
331
- .where('id', '=', account.id)
332
- .execute();
333
-
334
- return { success: true, amount };
335
- }
336
-
337
- return { success: false, amount };
338
- }),
339
- ),
340
- );
341
-
342
- // Verify that withdrawals were serialized correctly
343
- const finalAccount = await db
344
- .selectFrom('kyselyTrxAccounts')
345
- .selectAll()
346
- .where('id', '=', account.id)
347
- .executeTakeFirstOrThrow();
348
-
349
- const totalWithdrawn = results
350
- .filter((r) => r.success)
351
- .reduce((sum, r) => sum + r.amount, 0);
352
-
353
- expect(Number(finalAccount.balance)).toBe(500 - totalWithdrawn);
354
- expect(finalAccount.version).toBe(
355
- results.filter((r) => r.success).length,
356
- );
357
- });
358
- });
359
-
360
- describe('Isolation Levels', () => {
361
- it('should set READ COMMITTED isolation level', async () => {
362
- const result = await withTransaction(
363
- db,
364
- async (trx) => {
365
- // Insert a user
366
- const user = await trx
367
- .insertInto('kyselyTrxUsers')
368
- .values({
369
- name: 'Read Committed User',
370
- email: 'readcommitted@example.com',
371
- })
372
- .returningAll()
373
- .executeTakeFirstOrThrow();
374
-
375
- return user;
376
- },
377
- { isolationLevel: 'read committed' },
378
- );
379
-
380
- expect(result).toBeDefined();
381
- expect(result.name).toBe('Read Committed User');
382
- });
383
-
384
- it('should set REPEATABLE READ isolation level', async () => {
385
- const result = await withTransaction(
386
- db,
387
- async (trx) => {
388
- // Insert a user
389
- const user = await trx
390
- .insertInto('kyselyTrxUsers')
391
- .values({
392
- name: 'Repeatable Read User',
393
- email: 'repeatableread@example.com',
394
- })
395
- .returningAll()
396
- .executeTakeFirstOrThrow();
397
-
398
- return user;
399
- },
400
- { isolationLevel: 'repeatable read' },
401
- );
402
-
403
- expect(result).toBeDefined();
404
- expect(result.name).toBe('Repeatable Read User');
405
- });
406
-
407
- it('should set SERIALIZABLE isolation level', async () => {
408
- const result = await withTransaction(
409
- db,
410
- async (trx) => {
411
- // Insert a user
412
- const user = await trx
413
- .insertInto('kyselyTrxUsers')
414
- .values({
415
- name: 'Serializable User',
416
- email: 'serializable@example.com',
417
- })
418
- .returningAll()
419
- .executeTakeFirstOrThrow();
420
-
421
- return user;
422
- },
423
- { isolationLevel: 'serializable' },
424
- );
425
-
426
- expect(result).toBeDefined();
427
- expect(result.name).toBe('Serializable User');
428
- });
429
-
430
- it('should demonstrate READ COMMITTED allows non-repeatable reads', async () => {
431
- // Create a user first
432
- const user = await db
433
- .insertInto('kyselyTrxUsers')
434
- .values({
435
- name: 'Original Name',
436
- email: 'nonrepeatable@example.com',
437
- })
438
- .returningAll()
439
- .executeTakeFirstOrThrow();
440
-
441
- // Start a READ COMMITTED transaction
442
- const readTransaction = withTransaction(
443
- db,
444
- async (trx) => {
445
- // First read
446
- const firstUser = await trx
447
- .selectFrom('kyselyTrxUsers')
448
- .selectAll()
449
- .where('id', '=', user.id)
450
- .executeTakeFirstOrThrow();
451
-
452
- // Wait for concurrent update
453
- await new Promise((resolve) => setTimeout(resolve, 50));
454
-
455
- // Second read (should see the update in READ COMMITTED)
456
- const secondUser = await trx
457
- .selectFrom('kyselyTrxUsers')
458
- .selectAll()
459
- .where('id', '=', user.id)
460
- .executeTakeFirstOrThrow();
461
-
462
- return { firstRead: firstUser.name, secondRead: secondUser.name };
463
- },
464
- { isolationLevel: 'read committed' },
465
- );
466
-
467
- // After first read, update the user in a separate transaction
468
- setTimeout(() => {
469
- db.updateTable('kyselyTrxUsers')
470
- .set({ name: 'Updated Name' })
471
- .where('id', '=', user.id)
472
- .execute();
473
- }, 25);
474
-
475
- const result = await readTransaction;
476
-
477
- // In READ COMMITTED, the second read sees the committed update
478
- expect(result.firstRead).toBe('Original Name');
479
- expect(result.secondRead).toBe('Updated Name');
480
- });
481
-
482
- it('should demonstrate REPEATABLE READ prevents non-repeatable reads', async () => {
483
- // Create a user first
484
- const user = await db
485
- .insertInto('kyselyTrxUsers')
486
- .values({
487
- name: 'Repeatable Original',
488
- email: 'repeatable@example.com',
489
- })
490
- .returningAll()
491
- .executeTakeFirstOrThrow();
492
-
493
- // Start a REPEATABLE READ transaction
494
- const readTransaction = withTransaction(
495
- db,
496
- async (trx) => {
497
- // First read
498
- const firstUser = await trx
499
- .selectFrom('kyselyTrxUsers')
500
- .selectAll()
501
- .where('id', '=', user.id)
502
- .executeTakeFirstOrThrow();
503
-
504
- // Wait for concurrent update
505
- await new Promise((resolve) => setTimeout(resolve, 50));
506
-
507
- // Second read (should still see the same value in REPEATABLE READ)
508
- const secondUser = await trx
509
- .selectFrom('kyselyTrxUsers')
510
- .selectAll()
511
- .where('id', '=', user.id)
512
- .executeTakeFirstOrThrow();
513
-
514
- return { firstRead: firstUser.name, secondRead: secondUser.name };
515
- },
516
- { isolationLevel: 'repeatable read' },
517
- );
518
-
519
- // After first read, update the user in a separate transaction
520
- setTimeout(() => {
521
- db.updateTable('kyselyTrxUsers')
522
- .set({ name: 'Repeatable Updated' })
523
- .where('id', '=', user.id)
524
- .execute();
525
- }, 25);
526
-
527
- const result = await readTransaction;
528
-
529
- // In REPEATABLE READ, both reads see the same value
530
- expect(result.firstRead).toBe('Repeatable Original');
531
- expect(result.secondRead).toBe('Repeatable Original');
532
- });
533
-
534
- it('should demonstrate SERIALIZABLE prevents phantom reads', async () => {
535
- // Create initial users
536
- await db
537
- .insertInto('kyselyTrxUsers')
538
- .values([
539
- { name: 'User 1', email: 'user1@example.com' },
540
- { name: 'User 2', email: 'user2@example.com' },
541
- ])
542
- .execute();
543
-
544
- // Start a SERIALIZABLE transaction
545
- const readTransaction = withTransaction(
546
- db,
547
- async (trx) => {
548
- // First count
549
- const firstCount = await trx
550
- .selectFrom('kyselyTrxUsers')
551
- .select(sql<number>`count(*)`.as('count'))
552
- .executeTakeFirstOrThrow();
553
-
554
- // Wait for concurrent insert
555
- await new Promise((resolve) => setTimeout(resolve, 50));
556
-
557
- // Second count (should be the same in SERIALIZABLE)
558
- const secondCount = await trx
559
- .selectFrom('kyselyTrxUsers')
560
- .select(sql<number>`count(*)`.as('count'))
561
- .executeTakeFirstOrThrow();
562
-
563
- return {
564
- firstCount: Number(firstCount.count),
565
- secondCount: Number(secondCount.count),
566
- };
567
- },
568
- { isolationLevel: 'serializable' },
569
- );
570
-
571
- // After first count, insert a new user in a separate transaction
572
- setTimeout(() => {
573
- db.insertInto('kyselyTrxUsers')
574
- .values({ name: 'User 3', email: 'user3@example.com' })
575
- .execute();
576
- }, 25);
577
-
578
- const result = await readTransaction;
579
-
580
- // In SERIALIZABLE, both counts see the same number of rows
581
- expect(result.firstCount).toBe(2);
582
- expect(result.secondCount).toBe(2);
583
- });
584
-
585
- it('should not apply isolation level when reusing transaction', async () => {
586
- await withTransaction(
587
- db,
588
- async (trx1) => {
589
- // Insert user in outer transaction
590
- const user = await trx1
591
- .insertInto('kyselyTrxUsers')
592
- .values({
593
- name: 'Nested Transaction Test',
594
- email: 'nested@example.com',
595
- })
596
- .returningAll()
597
- .executeTakeFirstOrThrow();
598
-
599
- // Nested transaction with different isolation level
600
- // should be ignored since it reuses the outer transaction
601
- await withTransaction(
602
- trx1,
603
- async (trx2) => {
604
- const foundUser = await trx2
605
- .selectFrom('kyselyTrxUsers')
606
- .selectAll()
607
- .where('id', '=', user.id)
608
- .executeTakeFirstOrThrow();
609
-
610
- expect(foundUser.name).toBe('Nested Transaction Test');
611
- },
612
- { isolationLevel: 'serializable' },
613
- );
614
- },
615
- { isolationLevel: 'read committed' },
616
- );
617
- });
618
- });
619
-
620
- describe('Complex Transaction Scenarios', () => {
621
- it('should handle cascading deletes within transaction', async () => {
622
- await withTransaction(db, async (trx) => {
623
- // Create user and account
624
- const user = await trx
625
- .insertInto('kyselyTrxUsers')
626
- .values({
627
- name: 'Delete Test User',
628
- email: 'delete@example.com',
629
- })
630
- .returningAll()
631
- .executeTakeFirstOrThrow();
632
-
633
- await trx
634
- .insertInto('kyselyTrxAccounts')
635
- .values({
636
- userId: user.id,
637
- balance: 1000,
638
- version: 0,
639
- })
640
- .execute();
641
-
642
- // Delete user (should cascade to accounts)
643
- await trx
644
- .deleteFrom('kyselyTrxUsers')
645
- .where('id', '=', user.id)
646
- .execute();
647
-
648
- // Verify cascading delete worked
649
- const accounts = await trx
650
- .selectFrom('kyselyTrxAccounts')
651
- .selectAll()
652
- .where('userId', '=', user.id)
653
- .execute();
654
-
655
- expect(accounts).toHaveLength(0);
656
- });
657
- });
658
-
659
- it('should handle batch inserts within transaction', async () => {
660
- const userCount = await withTransaction(db, async (trx) => {
661
- // Batch insert users
662
- const users = await trx
663
- .insertInto('kyselyTrxUsers')
664
- .values([
665
- { name: 'Batch User 1', email: 'batch1@example.com' },
666
- { name: 'Batch User 2', email: 'batch2@example.com' },
667
- { name: 'Batch User 3', email: 'batch3@example.com' },
668
- { name: 'Batch User 4', email: 'batch4@example.com' },
669
- { name: 'Batch User 5', email: 'batch5@example.com' },
670
- ])
671
- .returningAll()
672
- .execute();
673
-
674
- expect(users).toHaveLength(5);
675
-
676
- // Count users
677
- const count = await trx
678
- .selectFrom('kyselyTrxUsers')
679
- .select(sql<number>`count(*)`.as('count'))
680
- .where('email', 'like', 'batch%')
681
- .executeTakeFirstOrThrow();
682
-
683
- return Number(count.count);
684
- });
685
-
686
- expect(userCount).toBe(5);
687
- });
688
-
689
- it('should handle complex queries with joins', async () => {
690
- await withTransaction(db, async (trx) => {
691
- // Create test data
692
- const user1 = await trx
693
- .insertInto('kyselyTrxUsers')
694
- .values({ name: 'Author 1', email: 'author1@example.com' })
695
- .returningAll()
696
- .executeTakeFirstOrThrow();
697
-
698
- const user2 = await trx
699
- .insertInto('kyselyTrxUsers')
700
- .values({ name: 'Author 2', email: 'author2@example.com' })
701
- .returningAll()
702
- .executeTakeFirstOrThrow();
703
-
704
- await trx
705
- .insertInto('kyselyTrxAccounts')
706
- .values([
707
- { userId: user1.id, balance: 1000, version: 0 },
708
- { userId: user1.id, balance: 2000, version: 0 },
709
- { userId: user2.id, balance: 3000, version: 0 },
710
- ])
711
- .execute();
712
-
713
- // Complex query with joins
714
- const results = await trx
715
- .selectFrom('kyselyTrxUsers')
716
- .leftJoin(
717
- 'kyselyTrxAccounts',
718
- 'kyselyTrxUsers.id',
719
- 'kyselyTrxAccounts.userId',
720
- )
721
- .select([
722
- 'kyselyTrxUsers.id as userId',
723
- 'kyselyTrxUsers.name',
724
- sql<number>`count(kysely_trx_accounts.id)`.as('accountCount'),
725
- sql<number>`coalesce(sum(kysely_trx_accounts.balance), 0)`.as(
726
- 'totalBalance',
727
- ),
728
- ])
729
- .groupBy(['kyselyTrxUsers.id', 'kyselyTrxUsers.name'])
730
- .orderBy('kyselyTrxUsers.id')
731
- .execute();
732
-
733
- expect(results).toHaveLength(2);
734
- expect(Number(results[0].accountCount)).toBe(2);
735
- expect(Number(results[0].totalBalance)).toBe(3000);
736
- expect(Number(results[1].accountCount)).toBe(1);
737
- expect(Number(results[1].totalBalance)).toBe(3000);
738
- });
739
- });
740
- });
29
+ let db: Kysely<TestDatabase>;
30
+
31
+ beforeAll(async () => {
32
+ db = new Kysely<TestDatabase>({
33
+ dialect: new PostgresDialect({
34
+ pool: new pg.Pool({
35
+ ...TEST_DATABASE_CONFIG,
36
+ database: 'postgres',
37
+ }),
38
+ }),
39
+ plugins: [new CamelCasePlugin()],
40
+ });
41
+
42
+ // Create users table
43
+ await db.schema
44
+ .createTable('kysely_trx_users')
45
+ .ifNotExists()
46
+ .addColumn('id', 'serial', (col) => col.primaryKey())
47
+ .addColumn('name', 'varchar', (col) => col.notNull())
48
+ .addColumn('email', 'varchar', (col) => col.notNull().unique())
49
+ .addColumn('created_at', 'timestamp', (col) =>
50
+ col.defaultTo(sql`now()`).notNull(),
51
+ )
52
+ .execute();
53
+
54
+ // Create accounts table
55
+ await db.schema
56
+ .createTable('kysely_trx_accounts')
57
+ .ifNotExists()
58
+ .addColumn('id', 'serial', (col) => col.primaryKey())
59
+ .addColumn('user_id', 'integer', (col) =>
60
+ col.notNull().references('kysely_trx_users.id').onDelete('cascade'),
61
+ )
62
+ .addColumn('balance', 'numeric(10, 2)', (col) =>
63
+ col.notNull().defaultTo(0),
64
+ )
65
+ .addColumn('version', 'integer', (col) => col.notNull().defaultTo(0))
66
+ .execute();
67
+ });
68
+
69
+ afterEach(async () => {
70
+ // Clean up data after each test
71
+ await db.deleteFrom('kyselyTrxAccounts').execute();
72
+ await db.deleteFrom('kyselyTrxUsers').execute();
73
+ });
74
+
75
+ afterAll(async () => {
76
+ // Drop tables and close connection
77
+ await db.schema.dropTable('kysely_trx_accounts').ifExists().execute();
78
+ await db.schema.dropTable('kysely_trx_users').ifExists().execute();
79
+ await db.destroy();
80
+ });
81
+
82
+ describe('withTransaction - Real Database Operations', () => {
83
+ it('should execute real insert and select within transaction', async () => {
84
+ const result = await withTransaction(db, async (trx) => {
85
+ // Insert a user
86
+ const user = await trx
87
+ .insertInto('kyselyTrxUsers')
88
+ .values({
89
+ name: 'John Doe',
90
+ email: 'john@example.com',
91
+ })
92
+ .returningAll()
93
+ .executeTakeFirstOrThrow();
94
+
95
+ // Verify we can select it within the same transaction
96
+ const foundUser = await trx
97
+ .selectFrom('kyselyTrxUsers')
98
+ .selectAll()
99
+ .where('id', '=', user.id)
100
+ .executeTakeFirstOrThrow();
101
+
102
+ expect(foundUser.name).toBe('John Doe');
103
+ expect(foundUser.email).toBe('john@example.com');
104
+
105
+ return user;
106
+ });
107
+
108
+ expect(result).toBeDefined();
109
+ expect(result.id).toBeDefined();
110
+ expect(result.name).toBe('John Doe');
111
+ });
112
+
113
+ it('should rollback on error', async () => {
114
+ const insertPromise = withTransaction(db, async (trx) => {
115
+ // Insert a user
116
+ await trx
117
+ .insertInto('kyselyTrxUsers')
118
+ .values({
119
+ name: 'Will Be Rolled Back',
120
+ email: 'rollback@example.com',
121
+ })
122
+ .execute();
123
+
124
+ // Throw an error
125
+ throw new Error('Transaction should rollback');
126
+ });
127
+
128
+ await expect(insertPromise).rejects.toThrow(
129
+ 'Transaction should rollback',
130
+ );
131
+
132
+ // Verify user was not created
133
+ const users = await db
134
+ .selectFrom('kyselyTrxUsers')
135
+ .selectAll()
136
+ .where('email', '=', 'rollback@example.com')
137
+ .execute();
138
+
139
+ expect(users).toHaveLength(0);
140
+ });
141
+
142
+ it('should reuse existing transaction', async () => {
143
+ await withTransaction(db, async (trx1) => {
144
+ // Insert user in outer transaction
145
+ const user = await trx1
146
+ .insertInto('kyselyTrxUsers')
147
+ .values({
148
+ name: 'Outer Transaction',
149
+ email: 'outer@example.com',
150
+ })
151
+ .returningAll()
152
+ .executeTakeFirstOrThrow();
153
+
154
+ // Nested transaction should reuse the same transaction
155
+ await withTransaction(trx1, async (trx2) => {
156
+ // Should see the user from outer transaction
157
+ const foundUser = await trx2
158
+ .selectFrom('kyselyTrxUsers')
159
+ .selectAll()
160
+ .where('id', '=', user.id)
161
+ .executeTakeFirstOrThrow();
162
+
163
+ expect(foundUser.name).toBe('Outer Transaction');
164
+
165
+ // Insert an account
166
+ await trx2
167
+ .insertInto('kyselyTrxAccounts')
168
+ .values({
169
+ userId: user.id,
170
+ balance: 1000,
171
+ version: 0,
172
+ })
173
+ .execute();
174
+ });
175
+
176
+ // Verify account was created in the same transaction
177
+ const accounts = await trx1
178
+ .selectFrom('kyselyTrxAccounts')
179
+ .selectAll()
180
+ .where('userId', '=', user.id)
181
+ .execute();
182
+
183
+ expect(accounts).toHaveLength(1);
184
+ });
185
+ });
186
+ });
187
+
188
+ describe('SELECT FOR UPDATE', () => {
189
+ it('should lock row with SELECT FOR UPDATE', async () => {
190
+ // Create a user and account
191
+ const user = await db
192
+ .insertInto('kyselyTrxUsers')
193
+ .values({
194
+ name: 'Test User',
195
+ email: 'test@example.com',
196
+ })
197
+ .returningAll()
198
+ .executeTakeFirstOrThrow();
199
+
200
+ const account = await db
201
+ .insertInto('kyselyTrxAccounts')
202
+ .values({
203
+ userId: user.id,
204
+ balance: 1000,
205
+ version: 0,
206
+ })
207
+ .returningAll()
208
+ .executeTakeFirstOrThrow();
209
+
210
+ // Start two concurrent transactions
211
+ const transaction1Promise = withTransaction(db, async (trx1) => {
212
+ // Lock the account row
213
+ const lockedAccount = await trx1
214
+ .selectFrom('kyselyTrxAccounts')
215
+ .selectAll()
216
+ .where('id', '=', account.id)
217
+ .forUpdate()
218
+ .executeTakeFirstOrThrow();
219
+
220
+ expect(lockedAccount.balance).toBe('1000.00');
221
+
222
+ // Simulate some processing time
223
+ await new Promise((resolve) => setTimeout(resolve, 100));
224
+
225
+ // Update the balance
226
+ await trx1
227
+ .updateTable('kyselyTrxAccounts')
228
+ .set({
229
+ balance: sql`balance - 100`,
230
+ version: sql`version + 1`,
231
+ })
232
+ .where('id', '=', account.id)
233
+ .execute();
234
+
235
+ return 'transaction1-complete';
236
+ });
237
+
238
+ // Wait a bit to ensure transaction1 has acquired the lock
239
+ await new Promise((resolve) => setTimeout(resolve, 10));
240
+
241
+ // This transaction should wait for the lock
242
+ const transaction2Promise = withTransaction(db, async (trx2) => {
243
+ // This will wait for transaction1 to release the lock
244
+ const lockedAccount = await trx2
245
+ .selectFrom('kyselyTrxAccounts')
246
+ .selectAll()
247
+ .where('id', '=', account.id)
248
+ .forUpdate()
249
+ .executeTakeFirstOrThrow();
250
+
251
+ // By the time we get here, transaction1 should have completed
252
+ expect(Number(lockedAccount.balance)).toBe(900);
253
+
254
+ // Update the balance again
255
+ await trx2
256
+ .updateTable('kyselyTrxAccounts')
257
+ .set({
258
+ balance: sql`balance - 50`,
259
+ version: sql`version + 1`,
260
+ })
261
+ .where('id', '=', account.id)
262
+ .execute();
263
+
264
+ return 'transaction2-complete';
265
+ });
266
+
267
+ // Wait for both transactions to complete
268
+ const results = await Promise.all([
269
+ transaction1Promise,
270
+ transaction2Promise,
271
+ ]);
272
+
273
+ expect(results).toEqual([
274
+ 'transaction1-complete',
275
+ 'transaction2-complete',
276
+ ]);
277
+
278
+ // Verify final balance
279
+ const finalAccount = await db
280
+ .selectFrom('kyselyTrxAccounts')
281
+ .selectAll()
282
+ .where('id', '=', account.id)
283
+ .executeTakeFirstOrThrow();
284
+
285
+ expect(Number(finalAccount.balance)).toBe(850);
286
+ expect(finalAccount.version).toBe(2);
287
+ });
288
+
289
+ it('should prevent concurrent updates with SELECT FOR UPDATE', async () => {
290
+ // Create user and account
291
+ const user = await db
292
+ .insertInto('kyselyTrxUsers')
293
+ .values({
294
+ name: 'Concurrent User',
295
+ email: 'concurrent@example.com',
296
+ })
297
+ .returningAll()
298
+ .executeTakeFirstOrThrow();
299
+
300
+ const account = await db
301
+ .insertInto('kyselyTrxAccounts')
302
+ .values({
303
+ userId: user.id,
304
+ balance: 500,
305
+ version: 0,
306
+ })
307
+ .returningAll()
308
+ .executeTakeFirstOrThrow();
309
+
310
+ // Perform multiple concurrent withdrawals
311
+ const withdrawals = [100, 150, 200];
312
+ const results = await Promise.all(
313
+ withdrawals.map((amount) =>
314
+ withTransaction(db, async (trx) => {
315
+ // Lock the row
316
+ const currentAccount = await trx
317
+ .selectFrom('kyselyTrxAccounts')
318
+ .selectAll()
319
+ .where('id', '=', account.id)
320
+ .forUpdate()
321
+ .executeTakeFirstOrThrow();
322
+
323
+ // Check if there's enough balance
324
+ if (Number(currentAccount.balance) >= amount) {
325
+ await trx
326
+ .updateTable('kyselyTrxAccounts')
327
+ .set({
328
+ balance: sql`balance - ${amount}`,
329
+ version: sql`version + 1`,
330
+ })
331
+ .where('id', '=', account.id)
332
+ .execute();
333
+
334
+ return { success: true, amount };
335
+ }
336
+
337
+ return { success: false, amount };
338
+ }),
339
+ ),
340
+ );
341
+
342
+ // Verify that withdrawals were serialized correctly
343
+ const finalAccount = await db
344
+ .selectFrom('kyselyTrxAccounts')
345
+ .selectAll()
346
+ .where('id', '=', account.id)
347
+ .executeTakeFirstOrThrow();
348
+
349
+ const totalWithdrawn = results
350
+ .filter((r) => r.success)
351
+ .reduce((sum, r) => sum + r.amount, 0);
352
+
353
+ expect(Number(finalAccount.balance)).toBe(500 - totalWithdrawn);
354
+ expect(finalAccount.version).toBe(
355
+ results.filter((r) => r.success).length,
356
+ );
357
+ });
358
+ });
359
+
360
+ describe('Isolation Levels', () => {
361
+ it('should set READ COMMITTED isolation level', async () => {
362
+ const result = await withTransaction(
363
+ db,
364
+ async (trx) => {
365
+ // Insert a user
366
+ const user = await trx
367
+ .insertInto('kyselyTrxUsers')
368
+ .values({
369
+ name: 'Read Committed User',
370
+ email: 'readcommitted@example.com',
371
+ })
372
+ .returningAll()
373
+ .executeTakeFirstOrThrow();
374
+
375
+ return user;
376
+ },
377
+ { isolationLevel: 'read committed' },
378
+ );
379
+
380
+ expect(result).toBeDefined();
381
+ expect(result.name).toBe('Read Committed User');
382
+ });
383
+
384
+ it('should set REPEATABLE READ isolation level', async () => {
385
+ const result = await withTransaction(
386
+ db,
387
+ async (trx) => {
388
+ // Insert a user
389
+ const user = await trx
390
+ .insertInto('kyselyTrxUsers')
391
+ .values({
392
+ name: 'Repeatable Read User',
393
+ email: 'repeatableread@example.com',
394
+ })
395
+ .returningAll()
396
+ .executeTakeFirstOrThrow();
397
+
398
+ return user;
399
+ },
400
+ { isolationLevel: 'repeatable read' },
401
+ );
402
+
403
+ expect(result).toBeDefined();
404
+ expect(result.name).toBe('Repeatable Read User');
405
+ });
406
+
407
+ it('should set SERIALIZABLE isolation level', async () => {
408
+ const result = await withTransaction(
409
+ db,
410
+ async (trx) => {
411
+ // Insert a user
412
+ const user = await trx
413
+ .insertInto('kyselyTrxUsers')
414
+ .values({
415
+ name: 'Serializable User',
416
+ email: 'serializable@example.com',
417
+ })
418
+ .returningAll()
419
+ .executeTakeFirstOrThrow();
420
+
421
+ return user;
422
+ },
423
+ { isolationLevel: 'serializable' },
424
+ );
425
+
426
+ expect(result).toBeDefined();
427
+ expect(result.name).toBe('Serializable User');
428
+ });
429
+
430
+ it('should demonstrate READ COMMITTED allows non-repeatable reads', async () => {
431
+ // Create a user first
432
+ const user = await db
433
+ .insertInto('kyselyTrxUsers')
434
+ .values({
435
+ name: 'Original Name',
436
+ email: 'nonrepeatable@example.com',
437
+ })
438
+ .returningAll()
439
+ .executeTakeFirstOrThrow();
440
+
441
+ // Start a READ COMMITTED transaction
442
+ const readTransaction = withTransaction(
443
+ db,
444
+ async (trx) => {
445
+ // First read
446
+ const firstUser = await trx
447
+ .selectFrom('kyselyTrxUsers')
448
+ .selectAll()
449
+ .where('id', '=', user.id)
450
+ .executeTakeFirstOrThrow();
451
+
452
+ // Wait for concurrent update
453
+ await new Promise((resolve) => setTimeout(resolve, 50));
454
+
455
+ // Second read (should see the update in READ COMMITTED)
456
+ const secondUser = await trx
457
+ .selectFrom('kyselyTrxUsers')
458
+ .selectAll()
459
+ .where('id', '=', user.id)
460
+ .executeTakeFirstOrThrow();
461
+
462
+ return { firstRead: firstUser.name, secondRead: secondUser.name };
463
+ },
464
+ { isolationLevel: 'read committed' },
465
+ );
466
+
467
+ // After first read, update the user in a separate transaction
468
+ setTimeout(() => {
469
+ db.updateTable('kyselyTrxUsers')
470
+ .set({ name: 'Updated Name' })
471
+ .where('id', '=', user.id)
472
+ .execute();
473
+ }, 25);
474
+
475
+ const result = await readTransaction;
476
+
477
+ // In READ COMMITTED, the second read sees the committed update
478
+ expect(result.firstRead).toBe('Original Name');
479
+ expect(result.secondRead).toBe('Updated Name');
480
+ });
481
+
482
+ it('should demonstrate REPEATABLE READ prevents non-repeatable reads', async () => {
483
+ // Create a user first
484
+ const user = await db
485
+ .insertInto('kyselyTrxUsers')
486
+ .values({
487
+ name: 'Repeatable Original',
488
+ email: 'repeatable@example.com',
489
+ })
490
+ .returningAll()
491
+ .executeTakeFirstOrThrow();
492
+
493
+ // Start a REPEATABLE READ transaction
494
+ const readTransaction = withTransaction(
495
+ db,
496
+ async (trx) => {
497
+ // First read
498
+ const firstUser = await trx
499
+ .selectFrom('kyselyTrxUsers')
500
+ .selectAll()
501
+ .where('id', '=', user.id)
502
+ .executeTakeFirstOrThrow();
503
+
504
+ // Wait for concurrent update
505
+ await new Promise((resolve) => setTimeout(resolve, 50));
506
+
507
+ // Second read (should still see the same value in REPEATABLE READ)
508
+ const secondUser = await trx
509
+ .selectFrom('kyselyTrxUsers')
510
+ .selectAll()
511
+ .where('id', '=', user.id)
512
+ .executeTakeFirstOrThrow();
513
+
514
+ return { firstRead: firstUser.name, secondRead: secondUser.name };
515
+ },
516
+ { isolationLevel: 'repeatable read' },
517
+ );
518
+
519
+ // After first read, update the user in a separate transaction
520
+ setTimeout(() => {
521
+ db.updateTable('kyselyTrxUsers')
522
+ .set({ name: 'Repeatable Updated' })
523
+ .where('id', '=', user.id)
524
+ .execute();
525
+ }, 25);
526
+
527
+ const result = await readTransaction;
528
+
529
+ // In REPEATABLE READ, both reads see the same value
530
+ expect(result.firstRead).toBe('Repeatable Original');
531
+ expect(result.secondRead).toBe('Repeatable Original');
532
+ });
533
+
534
+ it('should demonstrate SERIALIZABLE prevents phantom reads', async () => {
535
+ // Create initial users
536
+ await db
537
+ .insertInto('kyselyTrxUsers')
538
+ .values([
539
+ { name: 'User 1', email: 'user1@example.com' },
540
+ { name: 'User 2', email: 'user2@example.com' },
541
+ ])
542
+ .execute();
543
+
544
+ // Start a SERIALIZABLE transaction
545
+ const readTransaction = withTransaction(
546
+ db,
547
+ async (trx) => {
548
+ // First count
549
+ const firstCount = await trx
550
+ .selectFrom('kyselyTrxUsers')
551
+ .select(sql<number>`count(*)`.as('count'))
552
+ .executeTakeFirstOrThrow();
553
+
554
+ // Wait for concurrent insert
555
+ await new Promise((resolve) => setTimeout(resolve, 50));
556
+
557
+ // Second count (should be the same in SERIALIZABLE)
558
+ const secondCount = await trx
559
+ .selectFrom('kyselyTrxUsers')
560
+ .select(sql<number>`count(*)`.as('count'))
561
+ .executeTakeFirstOrThrow();
562
+
563
+ return {
564
+ firstCount: Number(firstCount.count),
565
+ secondCount: Number(secondCount.count),
566
+ };
567
+ },
568
+ { isolationLevel: 'serializable' },
569
+ );
570
+
571
+ // After first count, insert a new user in a separate transaction
572
+ setTimeout(() => {
573
+ db.insertInto('kyselyTrxUsers')
574
+ .values({ name: 'User 3', email: 'user3@example.com' })
575
+ .execute();
576
+ }, 25);
577
+
578
+ const result = await readTransaction;
579
+
580
+ // In SERIALIZABLE, both counts see the same number of rows
581
+ expect(result.firstCount).toBe(2);
582
+ expect(result.secondCount).toBe(2);
583
+ });
584
+
585
+ it('should not apply isolation level when reusing transaction', async () => {
586
+ await withTransaction(
587
+ db,
588
+ async (trx1) => {
589
+ // Insert user in outer transaction
590
+ const user = await trx1
591
+ .insertInto('kyselyTrxUsers')
592
+ .values({
593
+ name: 'Nested Transaction Test',
594
+ email: 'nested@example.com',
595
+ })
596
+ .returningAll()
597
+ .executeTakeFirstOrThrow();
598
+
599
+ // Nested transaction with different isolation level
600
+ // should be ignored since it reuses the outer transaction
601
+ await withTransaction(
602
+ trx1,
603
+ async (trx2) => {
604
+ const foundUser = await trx2
605
+ .selectFrom('kyselyTrxUsers')
606
+ .selectAll()
607
+ .where('id', '=', user.id)
608
+ .executeTakeFirstOrThrow();
609
+
610
+ expect(foundUser.name).toBe('Nested Transaction Test');
611
+ },
612
+ { isolationLevel: 'serializable' },
613
+ );
614
+ },
615
+ { isolationLevel: 'read committed' },
616
+ );
617
+ });
618
+ });
619
+
620
+ describe('Complex Transaction Scenarios', () => {
621
+ it('should handle cascading deletes within transaction', async () => {
622
+ await withTransaction(db, async (trx) => {
623
+ // Create user and account
624
+ const user = await trx
625
+ .insertInto('kyselyTrxUsers')
626
+ .values({
627
+ name: 'Delete Test User',
628
+ email: 'delete@example.com',
629
+ })
630
+ .returningAll()
631
+ .executeTakeFirstOrThrow();
632
+
633
+ await trx
634
+ .insertInto('kyselyTrxAccounts')
635
+ .values({
636
+ userId: user.id,
637
+ balance: 1000,
638
+ version: 0,
639
+ })
640
+ .execute();
641
+
642
+ // Delete user (should cascade to accounts)
643
+ await trx
644
+ .deleteFrom('kyselyTrxUsers')
645
+ .where('id', '=', user.id)
646
+ .execute();
647
+
648
+ // Verify cascading delete worked
649
+ const accounts = await trx
650
+ .selectFrom('kyselyTrxAccounts')
651
+ .selectAll()
652
+ .where('userId', '=', user.id)
653
+ .execute();
654
+
655
+ expect(accounts).toHaveLength(0);
656
+ });
657
+ });
658
+
659
+ it('should handle batch inserts within transaction', async () => {
660
+ const userCount = await withTransaction(db, async (trx) => {
661
+ // Batch insert users
662
+ const users = await trx
663
+ .insertInto('kyselyTrxUsers')
664
+ .values([
665
+ { name: 'Batch User 1', email: 'batch1@example.com' },
666
+ { name: 'Batch User 2', email: 'batch2@example.com' },
667
+ { name: 'Batch User 3', email: 'batch3@example.com' },
668
+ { name: 'Batch User 4', email: 'batch4@example.com' },
669
+ { name: 'Batch User 5', email: 'batch5@example.com' },
670
+ ])
671
+ .returningAll()
672
+ .execute();
673
+
674
+ expect(users).toHaveLength(5);
675
+
676
+ // Count users
677
+ const count = await trx
678
+ .selectFrom('kyselyTrxUsers')
679
+ .select(sql<number>`count(*)`.as('count'))
680
+ .where('email', 'like', 'batch%')
681
+ .executeTakeFirstOrThrow();
682
+
683
+ return Number(count.count);
684
+ });
685
+
686
+ expect(userCount).toBe(5);
687
+ });
688
+
689
+ it('should handle complex queries with joins', async () => {
690
+ await withTransaction(db, async (trx) => {
691
+ // Create test data
692
+ const user1 = await trx
693
+ .insertInto('kyselyTrxUsers')
694
+ .values({ name: 'Author 1', email: 'author1@example.com' })
695
+ .returningAll()
696
+ .executeTakeFirstOrThrow();
697
+
698
+ const user2 = await trx
699
+ .insertInto('kyselyTrxUsers')
700
+ .values({ name: 'Author 2', email: 'author2@example.com' })
701
+ .returningAll()
702
+ .executeTakeFirstOrThrow();
703
+
704
+ await trx
705
+ .insertInto('kyselyTrxAccounts')
706
+ .values([
707
+ { userId: user1.id, balance: 1000, version: 0 },
708
+ { userId: user1.id, balance: 2000, version: 0 },
709
+ { userId: user2.id, balance: 3000, version: 0 },
710
+ ])
711
+ .execute();
712
+
713
+ // Complex query with joins
714
+ const results = await trx
715
+ .selectFrom('kyselyTrxUsers')
716
+ .leftJoin(
717
+ 'kyselyTrxAccounts',
718
+ 'kyselyTrxUsers.id',
719
+ 'kyselyTrxAccounts.userId',
720
+ )
721
+ .select([
722
+ 'kyselyTrxUsers.id as userId',
723
+ 'kyselyTrxUsers.name',
724
+ sql<number>`count(kysely_trx_accounts.id)`.as('accountCount'),
725
+ sql<number>`coalesce(sum(kysely_trx_accounts.balance), 0)`.as(
726
+ 'totalBalance',
727
+ ),
728
+ ])
729
+ .groupBy(['kyselyTrxUsers.id', 'kyselyTrxUsers.name'])
730
+ .orderBy('kyselyTrxUsers.id')
731
+ .execute();
732
+
733
+ expect(results).toHaveLength(2);
734
+ expect(Number(results[0].accountCount)).toBe(2);
735
+ expect(Number(results[0].totalBalance)).toBe(3000);
736
+ expect(Number(results[1].accountCount)).toBe(1);
737
+ expect(Number(results[1].totalBalance)).toBe(3000);
738
+ });
739
+ });
740
+ });
741
741
  });